From 555ba7cf53e765ca3f5ebd84a1aff01112378725 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 7 Feb 2026 19:10:53 +0000 Subject: [PATCH] Lots of frontend changes --- frontend/src/App.tsx | 32 ++- .../components/data-sources/DataSources.tsx | 2 +- frontend/src/components/faq/FAQPage.tsx | 12 +- frontend/src/components/map/AreaPane.tsx | 205 +++++++++++---- frontend/src/components/map/DualHistogram.tsx | 76 +++++- frontend/src/components/map/Filters.tsx | 150 +++++------ frontend/src/components/map/Map.tsx | 34 ++- frontend/src/components/map/MapLegend.tsx | 21 +- frontend/src/components/map/MapPage.tsx | 50 +++- frontend/src/components/map/POIPane.tsx | 245 ++++++++---------- .../src/components/map/PostcodeSearch.tsx | 3 +- .../src/components/map/PriceHistoryChart.tsx | 235 +++++++++-------- .../src/components/map/PropertiesPane.tsx | 32 +-- .../src/components/map/StackedBarChart.tsx | 8 +- .../src/components/map/StackedEnumChart.tsx | 78 ++++++ frontend/src/components/ui/AuthModal.tsx | 154 +++++++++++ frontend/src/components/ui/FeatureLabel.tsx | 4 +- frontend/src/components/ui/Header.tsx | 139 +++++----- frontend/src/components/ui/UserMenu.tsx | 57 ++++ .../src/components/ui/icons/CheckIcon.tsx | 11 + .../src/components/ui/icons/ClipboardIcon.tsx | 15 ++ .../src/components/ui/icons/DownloadIcon.tsx | 12 + .../src/components/ui/icons/MapPinIcon.tsx | 20 ++ frontend/src/components/ui/icons/MoonIcon.tsx | 15 ++ .../src/components/ui/icons/SpinnerIcon.tsx | 12 + frontend/src/components/ui/icons/SunIcon.tsx | 15 ++ frontend/src/hooks/useAuth.ts | 108 ++++++++ frontend/src/hooks/useHexagonSelection.ts | 6 +- frontend/src/hooks/useMapData.ts | 39 ++- frontend/src/hooks/usePOIData.ts | 11 +- frontend/src/index.css | 1 + frontend/src/lib/api.ts | 52 ++-- frontend/src/lib/consts.ts | 116 ++++++--- frontend/src/lib/format.ts | 85 +++++- frontend/src/lib/map-utils.ts | 85 +++++- frontend/src/lib/pocketbase.ts | 5 + frontend/src/lib/utils.ts | 7 + frontend/src/types.ts | 4 +- 38 files changed, 1508 insertions(+), 648 deletions(-) create mode 100644 frontend/src/components/map/StackedEnumChart.tsx create mode 100644 frontend/src/components/ui/AuthModal.tsx create mode 100644 frontend/src/components/ui/UserMenu.tsx create mode 100644 frontend/src/components/ui/icons/CheckIcon.tsx create mode 100644 frontend/src/components/ui/icons/ClipboardIcon.tsx create mode 100644 frontend/src/components/ui/icons/DownloadIcon.tsx create mode 100644 frontend/src/components/ui/icons/MapPinIcon.tsx create mode 100644 frontend/src/components/ui/icons/MoonIcon.tsx create mode 100644 frontend/src/components/ui/icons/SpinnerIcon.tsx create mode 100644 frontend/src/components/ui/icons/SunIcon.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/lib/pocketbase.ts create mode 100644 frontend/src/lib/utils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5c98d4..a769b85 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,17 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { trackPageview } from './hooks/usePlausible'; -import MapPage from './components/map/MapPage'; +import MapPage, { type ExportState } from './components/map/MapPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage'; import FAQPage from './components/faq/FAQPage'; import HomePage from './components/home/HomePage'; import Header, { type Page } from './components/ui/Header'; +import AuthModal from './components/ui/AuthModal'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; 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 { useAuth } from './hooks/useAuth'; declare global { interface Window { @@ -43,6 +45,16 @@ export default function App() { }); const { theme, toggleTheme } = useTheme(); + const { + user, + loading: authLoading, + error: authError, + login, + register, + logout, + clearError, + } = useAuth(); + const [showAuthModal, setShowAuthModal] = useState(false); // Load features and POI categories on mount useEffect(() => { @@ -116,6 +128,8 @@ export default function App() { return () => window.removeEventListener('popstate', handlePopState); }, []); // eslint-disable-line react-hooks/exhaustive-deps + const [exportState, setExportState] = useState(null); + if (isScreenshotMode) { return ( setShowAuthModal(true)} + onLogout={logout} /> {activePage === 'home' ? ( navigateTo('dashboard')} theme={theme} /> @@ -162,6 +181,17 @@ export default function App() { pendingInfoFeature={pendingInfoFeature} onClearPendingInfoFeature={() => setPendingInfoFeature(null)} onNavigateTo={navigateTo} + onExportStateChange={setExportState} + /> + )} + {showAuthModal && ( + setShowAuthModal(false)} + onLogin={login} + onRegister={register} + loading={authLoading} + error={authError} + onClearError={clearError} /> )} diff --git a/frontend/src/components/data-sources/DataSources.tsx b/frontend/src/components/data-sources/DataSources.tsx index 386fb35..80cd48d 100644 --- a/frontend/src/components/data-sources/DataSources.tsx +++ b/frontend/src/components/data-sources/DataSources.tsx @@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void }) return ( diff --git a/frontend/src/components/faq/FAQPage.tsx b/frontend/src/components/faq/FAQPage.tsx index da6b582..4b21dce 100644 --- a/frontend/src/components/faq/FAQPage.tsx +++ b/frontend/src/components/faq/FAQPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ChevronIcon } from '../ui/icons/ChevronIcon'; interface FAQItem { question: string; @@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) { onClick={() => setOpen(!open)} > {item.question} - - - + /> {open && (
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 2080053..692ee8d 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from 'react'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types'; import type { HexagonLocation } from '../../lib/external-search'; -import { formatValue, calculateHistogramMean } from '../../lib/format'; +import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; -import { STACKED_GROUPS } from '../../lib/consts'; +import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; +import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon, CloseIcon } from '../ui/icons'; @@ -126,6 +127,14 @@ export default function AreaPane({ if (!hasData) return null; const stackedCharts = STACKED_GROUPS[group.name]; + const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name]; + + // Features that are part of a stacked enum config (rendered as compact charts) + const stackedEnumFeatureNames = new Set( + stackedEnumCharts?.flatMap((c) => + [c.feature, ...c.components].filter(Boolean) + ) as string[] ?? [] + ); return (
@@ -183,75 +192,157 @@ export default function AreaPane({
); }) - : // Default: render each feature individually - group.features.map((feature) => { - const numericStats = numericByName.get(feature.name); - const enumStats = enumByName.get(feature.name); + : // Default: render each feature individually (skip stacked enum features) + group.features + .filter((f) => !stackedEnumFeatureNames.has(f.name)) + .map((feature) => { + const numericStats = numericByName.get(feature.name); + const enumStats = enumByName.get(feature.name); - if (numericStats) { - const globalFeature = globalFeatureByName.get(feature.name); - const globalHistogram = globalFeature?.histogram; - const globalMean = globalHistogram - ? calculateHistogramMean(globalHistogram) - : undefined; + if (numericStats) { + const globalFeature = globalFeatureByName.get(feature.name); + const globalHistogram = globalFeature?.histogram; + const globalMean = globalHistogram + ? calculateHistogramMean(globalHistogram) + : undefined; - return ( -
-
- - - {formatValue(numericStats.mean)} - -
- {numericStats.histogram && ( - <> -
- {formatValue(numericStats.histogram.min)} - {formatValue(numericStats.histogram.max)} -
- {globalHistogram ? ( + return ( +
+
+ + + {formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])} + +
+ {numericStats.histogram && ( + globalHistogram ? ( ) : ( - )} - - )} -
- ); - } + ) + )} +
+ ); + } - if (enumStats) { - return ( -
- - -
- ); - } + if (enumStats) { + return ( +
+ + +
+ ); + } - return null; - })} + return null; + })} + {/* Stacked enum charts */} + {stackedEnumCharts?.map((chart) => { + const featureMeta = chart.feature + ? globalFeatureByName.get(chart.feature) + : undefined; + + // Single component: render as a stacked bar (like crime charts) + if (chart.components.length === 1) { + const stats = enumByName.get(chart.components[0]); + if (!stats) return null; + + const segments = chart.valueOrder + .map((value) => ({ name: value, value: stats.counts[value] ?? 0 })) + .filter((s) => s.value > 0); + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (total === 0) return null; + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} + + {total.toLocaleString()} + +
+ [v, chart.valueColors[i]]) + )} + /> +
+ ); + } + + // Multi-component: render as compact multi-row chart (like risk features) + const components = chart.components + .map((name) => { + const stats = enumByName.get(name); + return stats ? { label: name, stats } : null; + }) + .filter((c): c is NonNullable => c !== null); + + if (components.length === 0) return null; + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} +
+ +
+ ); + })}
); diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx index 01ff9ae..3638d30 100644 --- a/frontend/src/components/map/DualHistogram.tsx +++ b/frontend/src/components/map/DualHistogram.tsx @@ -11,18 +11,37 @@ function downsampleBars(counts: number[], targetBars: number): number[] { return bars; } +function pickTicks(min: number, max: number, count: number): number[] { + if (max <= min) return [min]; + const range = max - min; + const rawStep = range / (count - 1); + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10; + const step = nice * magnitude; + const start = Math.ceil(min / step) * step; + const ticks: number[] = []; + for (let v = start; v <= max + step * 0.01; v += step) { + ticks.push(v); + } + // Ensure at least min and max are represented + if (ticks.length === 0) return [min, max]; + return ticks; +} + export function DualHistogram({ localCounts, globalCounts, - min, - max, + p1, + p99, globalMean, + formatLabel, }: { localCounts: number[]; globalCounts: number[]; - min: number; - max: number; + p1: number; + p99: number; globalMean?: number; + formatLabel?: (value: number) => string; }) { const targetBars = 25; const localBars = downsampleBars(localCounts, targetBars); @@ -32,7 +51,37 @@ export function DualHistogram({ const localMax = Math.max(...localBars, 1); const globalMax = Math.max(...globalBars, 1); - const meanFraction = globalMean != null && max > min ? (globalMean - min) / (max - min) : null; + const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1))); + + // Compute center value for each bar. + // Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier. + const middleBins = Math.max(barCount - 2, 0); + const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; + const barCenters: number[] = Array.from({ length: barCount }, (_, i) => { + if (i === 0) return p1; // outlier bin, label as p1 + if (i === barCount - 1) return p99; // outlier bin, label as p99 + return p1 + (i - 1 + 0.5) * middleWidth; + }); + + // Pick nice tick values and assign each to the nearest bar + const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : []; + const tickBars = new Map(); // bar index → label + for (const v of ticks) { + let bestBar = 1; + let bestDist = Infinity; + for (let i = 1; i < barCount - 1; i++) { + const dist = Math.abs(barCenters[i] - v); + if (dist < bestDist) { bestDist = dist; bestBar = i; } + } + if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v)); + } + + // Mean line: position as fraction across the bar area + const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null; + // Account for outlier bins: middle region spans bars 1..n-2 + const meanPct = meanFrac != null + ? ((1 + meanFrac * middleBins) / barCount) * 100 + : null; return (
@@ -56,13 +105,26 @@ export function DualHistogram({
); })} - {meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && ( + {meanPct != null && meanPct >= 0 && meanPct <= 100 && (
)}
+ {tickBars.size > 0 && ( +
+ {Array.from({ length: barCount }).map((_, index) => ( +
+ {tickBars.has(index) && ( + + {tickBars.get(index)} + + )} +
+ ))} +
+ )} ); } diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index ed35658..b469b0b 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -1,8 +1,8 @@ -import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; +import { memo, useState, useMemo, useEffect } from 'react'; import { Slider } from '../ui/Slider'; import { Label } from '../ui/Label'; import { SearchInput } from '../ui/SearchInput'; -import { FilterIcon, LightbulbIcon } from '../ui/icons'; +import { ChevronIcon, FilterIcon, LightbulbIcon } from '../ui/icons'; import { EmptyState } from '../ui/EmptyState'; import type { FeatureMeta, FeatureFilters } from '../../types'; import { formatFilterValue } from '../../lib/format'; @@ -56,6 +56,15 @@ function FeatureBrowser({ }) { const [search, setSearch] = useState(''); const [infoFeature, setInfoFeature] = useState(null); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + const toggleGroup = (name: string) => + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); useEffect(() => { if (openInfoFeature) { @@ -73,50 +82,70 @@ function FeatureBrowser({ const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); + // When searching, expand all groups so results are visible + const isSearching = search.length > 0; + return ( <>
-
- {grouped.map((group) => ( -
-
- {group.name} -
- {group.features.map((f) => { - const isPinned = pinnedFeature === f.name; - return ( -
-
- - {f.description && ( - - {f.description} - - )} -
- +
+ {grouped.map((group) => { + const isExpanded = isSearching || expandedGroups.has(group.name); + return ( +
+
- ))} - {grouped.length === 0 && ( + + {isExpanded && + group.features.map((f) => { + const isPinned = pinnedFeature === f.name; + return ( +
+
+ + {f.description && ( + + {f.description} + + )} +
+ +
+ ); + })} +
+ ); + })} + {grouped.length === 0 ? ( } title={search ? 'No matching features' : 'All features are active'} description={search ? 'Try a different search term' : 'Remove a filter to see available features'} className="px-3 py-4" /> + ) : ( +

+ Everyone cares about different things. Pick the filters that matter most to you. +

)}
{infoFeature && ( @@ -155,38 +184,12 @@ export default memo(function Filters({ const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); - const containerRef = useRef(null); - const headerRef = useRef(null); - const [splitFraction, setSplitFraction] = useState(0.65); - const draggingRef = useRef(false); const [showPhilosophy, setShowPhilosophy] = useState(false); const [activeInfoFeature, setActiveInfoFeature] = useState(null); - const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - draggingRef.current = true; - }, []); - - const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => { - if (!draggingRef.current || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const headerHeight = headerRef.current?.offsetHeight ?? 0; - const y = e.clientY - rect.top; - const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height)); - setSplitFraction(fraction); - }, []); - - const handleSeparatorPointerUp = useCallback(() => { - draggingRef.current = false; - }, []); - return ( -
-
+
+
-
+
@@ -279,13 +282,11 @@ export default memo(function Filters({ key={feature.name} className={`space-y-1 p-3 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`} > +
-
- - - {formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])} - -
+ + {formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])} +
-
-
-
- -
+
Add Filter
diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index a288e18..39445ba 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -23,6 +23,8 @@ import { getBoundsFromViewState, emojiToTwemojiUrl, getMapStyle, + DENSITY_GRADIENT, + DENSITY_GRADIENT_DARK, } from '../../lib/map-utils'; import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts'; import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; @@ -161,7 +163,7 @@ export default memo(function Map({ const handleMapLoad = useCallback( (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { - // Hexagons render below roads/buildings/labels so map features show on top + // Road opacity is set in getMapStyle }, [] ); @@ -297,8 +299,15 @@ export default memo(function Map({ } }, []); - const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`; - const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`; + const isDark = theme === 'dark'; + const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; + const densityGradientRef = useRef(densityGradient); + densityGradientRef.current = densityGradient; + const isDarkRef = useRef(isDark); + isDarkRef.current = isDark; + + const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}`; + const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}|${theme}`; const hexLayer = useMemo( () => @@ -311,14 +320,15 @@ export default memo(function Map({ const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; + const dark = isDarkRef.current; if (vf && clr && cfm) { const val = d[`min_${vf}`]; - if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; + 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; const maxVal = d[`max_${vf}`] as number; if (maxVal < fr[0] || minVal > fr[1]) { - return [180, 180, 180, 60] as [number, number, number, number]; + return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; } } const range = clr[1] - clr[0]; @@ -330,7 +340,7 @@ export default memo(function Map({ const cr = countRangeRef.current; const c = d.count as number; const t = (c - cr.min) / (cr.max - cr.min); - return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [ + return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [ number, number, number, @@ -378,14 +388,15 @@ export default memo(function Map({ const clr = colorRangeRef.current; const fr = filterRangeRef.current; const cfm = colorFeatureMetaRef.current; + const dark = isDarkRef.current; if (vf && clr && cfm) { const val = d[`min_${vf}`]; - if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; + 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; const maxVal = d[`max_${vf}`] as number; if (maxVal < fr[0] || minVal > fr[1]) { - return [180, 180, 180, 60] as [number, number, number, number]; + return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number]; } } const range = clr[1] - clr[0]; @@ -397,7 +408,7 @@ export default memo(function Map({ 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))), 255] as [ + return [...countToColor(Math.max(0, Math.min(1, t)), densityGradientRef.current), 255] as [ number, number, number, @@ -406,11 +417,12 @@ export default memo(function Map({ }, getLineColor: (f) => { const pc = f.properties.postcode; + const dark = isDarkRef.current; if (pc === selectedPostcodeRef.current) return [255, 255, 255, 255] as [number, number, number, number]; if (pc === hoveredPostcodeRef.current) return [29, 228, 195, 200] as [number, number, number, number]; - return [100, 100, 100, 150] as [number, number, number, number]; + return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [number, number, number, number]; }, getLineWidth: (f) => { const pc = f.properties.postcode; @@ -570,6 +582,7 @@ export default memo(function Map({ onCancel={onCancelPin} mode="feature" enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined} + theme={theme} /> ) : ( )} {popupInfo && ( diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index ecbf3f8..3fc7770 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -1,4 +1,7 @@ import { formatValue } from '../../lib/format'; +import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; +import { gradientToCss } from '../../lib/utils'; +import { CloseIcon } from '../ui/icons/CloseIcon'; export default function MapLegend({ featureLabel, @@ -7,6 +10,7 @@ export default function MapLegend({ onCancel, mode, enumValues, + theme = 'light', }: { featureLabel: string; range: [number, number]; @@ -14,11 +18,10 @@ export default function MapLegend({ onCancel: () => void; mode: 'feature' | 'density'; enumValues?: string[]; + theme?: 'light' | 'dark'; }) { - const gradientStyle = - mode === 'density' - ? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))' - : 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))'; + const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; + const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT); return (
@@ -30,15 +33,7 @@ export default function MapLegend({ className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2" title="Clear color view" > - - - + )}
diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 479f279..f0561fb 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types'; import type { SearchedPostcode } from './PostcodeSearch'; import type { Page } from '../ui/Header'; @@ -14,6 +14,13 @@ import { usePOIData } from '../../hooks/usePOIData'; import { useFilters } from '../../hooks/useFilters'; import { useHexagonSelection } from '../../hooks/useHexagonSelection'; import { usePaneResize } from '../../hooks/usePaneResize'; +import { apiUrl, buildFilterString } from '../../lib/api'; +import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; + +export interface ExportState { + onExport: () => void; + exporting: boolean; +} interface MapPageProps { features: FeatureMeta[]; @@ -27,6 +34,7 @@ interface MapPageProps { pendingInfoFeature: string | null; onClearPendingInfoFeature: () => void; onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; + onExportStateChange?: (state: ExportState) => void; screenshotMode?: boolean; } @@ -42,6 +50,7 @@ export default function MapPage({ pendingInfoFeature, onClearPendingInfoFeature, onNavigateTo, + onExportStateChange, screenshotMode, }: MapPageProps) { if (screenshotMode) { @@ -142,15 +151,46 @@ export default function MapPage({ return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; }, [selection.selectedHexagon?.id, mapData.data, mapData.resolution]); + // Export to Excel + const [exporting, setExporting] = useState(false); + const handleExport = useCallback(() => { + if (!mapData.bounds || exporting) return; + const { south, west, north, east } = mapData.bounds; + const params = new URLSearchParams({ + bounds: `${south},${west},${north},${east}`, + }); + const filterStr = buildFilterString(filters, features); + if (filterStr) params.set('filters', filterStr); + const url = apiUrl('export', params); + + setExporting(true); + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then((blob) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'narrowit-export.xlsx'; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch((err) => console.error('Export failed:', err)) + .finally(() => setExporting(false)); + }, [mapData.bounds, filters, features, exporting]); + + // Report export state to parent (Header) + useEffect(() => { + onExportStateChange?.({ onExport: handleExport, exporting }); + }, [handleExport, exporting, onExportStateChange]); + return (
{initialLoading && (
- - - - +

Connecting to server...

diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index 4a8453b..ce69e63 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import type { POICategoryGroup } from '../../types'; -import { useClickOutside } from '../../hooks/useClickOutside'; import InfoPopup from '../ui/InfoPopup'; import { SearchInput } from '../ui/SearchInput'; import { InfoIcon, ChevronIcon } from '../ui/icons'; @@ -21,13 +20,9 @@ export default function POIPane({ poiCount, onNavigateToSource, }: POIPaneProps) { - const [dropdownOpen, setDropdownOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const [showInfo, setShowInfo] = useState(false); - const dropdownRef = useRef(null); - - useClickOutside(dropdownRef, () => setDropdownOpen(false)); const allCategories = groups.flatMap((g) => g.categories); @@ -93,139 +88,129 @@ export default function POIPane({ const selectedCount = selectedCategories.size; return ( -
-
-

Points of Interest

- setShowInfo(true)} title="Data source info"> - - -
+
+
+
+

Points of Interest

+ setShowInfo(true)} title="Data source info"> + + +
- {showInfo && ( - setShowInfo(false)} - sourceLink={ - onNavigateToSource - ? { - label: 'View data source', - onClick: () => { - onNavigateToSource('osm-pois'); - setShowInfo(false); - }, - } - : undefined - } - > -

- Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories - include public transport stops, shops, restaurants, healthcare facilities, leisure - venues, and more. Data is filtered and mapped to friendly names with exhaustive category - coverage. -

-
- )} + {showInfo && ( + setShowInfo(false)} + sourceLink={ + onNavigateToSource + ? { + label: 'View data source', + onClick: () => { + onNavigateToSource('osm-pois'); + setShowInfo(false); + }, + } + : undefined + } + > +

+ Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories + include public transport stops, shops, restaurants, healthcare facilities, leisure + venues, and more. Data is filtered and mapped to friendly names with exhaustive + category coverage. +

+
+ )} -
- + +
+ + {selectedCount}/{allCategories.length} selected - - +
- {dropdownOpen && ( -
-
- -
-
- {filteredGroups.map((group) => { - const groupSelected = group.categories.filter((c) => - selectedCategories.has(c) - ).length; - const allInGroupSelected = groupSelected === group.categories.length; - const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; - const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; - - return ( -
-
- - - - {groupSelected}/{group.categories.length} - -
- {!isCollapsed && - group.categories.map((category) => ( - - ))} -
- ); - })} -
+ {selectedCount > 0 && ( +
+ + {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible +
)}
- {selectedCount > 0 && ( -
-
- {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible -
-
- {selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected -
-
- )} +
+ {filteredGroups.map((group) => { + const groupSelected = group.categories.filter((c) => + selectedCategories.has(c) + ).length; + const allInGroupSelected = groupSelected === group.categories.length; + const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; + const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; -
-

Select categories to display POIs on the map.

-

Zoom in for better visibility of individual locations.

+ return ( +
+
+ + + + {groupSelected}/{group.categories.length} + +
+ {!isCollapsed && + group.categories.map((category) => ( + + ))} +
+ ); + })}
); diff --git a/frontend/src/components/map/PostcodeSearch.tsx b/frontend/src/components/map/PostcodeSearch.tsx index ec7c452..3403b10 100644 --- a/frontend/src/components/map/PostcodeSearch.tsx +++ b/frontend/src/components/map/PostcodeSearch.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import type { PostcodeGeometry } from '../../types'; +import { authHeaders } from '../../lib/api'; export interface SearchedPostcode { postcode: string; @@ -26,7 +27,7 @@ export default function PostcodeSearch({ setError(null); setLoading(true); try { - const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`); + const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders()); if (!res.ok) { setError('Postcode not found'); return; diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx index 2ee0205..7c450c2 100644 --- a/frontend/src/components/map/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -1,6 +1,6 @@ -import { useMemo } from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; import type { PricePoint } from '../../types'; -import { formatValue } from '../../lib/format'; +import { formatValue, FEATURE_FORMATS } from '../../lib/format'; interface PriceHistoryChartProps { points: PricePoint[]; @@ -8,141 +8,159 @@ interface PriceHistoryChartProps { const PADDING = { top: 8, right: 8, bottom: 20, left: 42 }; const HEIGHT = 120; +const priceFmt = FEATURE_FORMATS['Last known price']; export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { - const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + if (w > 0) setWidth(w); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => { let yMin = Infinity, - yMax = -Infinity, - pMin = Infinity, - pMax = -Infinity; + yMax = -Infinity; for (const p of points) { if (p.year < yMin) yMin = p.year; if (p.year > yMax) yMax = p.year; - if (p.price < pMin) pMin = p.price; - if (p.price > pMax) pMax = p.price; } - // Add 5% padding to price range - const pRange = pMax - pMin || 1; - pMin = Math.max(0, pMin - pRange * 0.05); - pMax = pMax + pRange * 0.05; - // Yearly averages - const byYear = new Map(); + // Use p5/p95 to clip outliers + const sorted = points.map((p) => p.price).sort((a, b) => a - b); + const p5 = sorted[Math.floor(sorted.length * 0.05)]; + const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))]; + const pRange = p95 - p5 || 1; + const pMin = Math.max(0, p5 - pRange * 0.1); + const pMax = p95 + pRange * 0.1; + + // Yearly medians (robust to outliers) + const byYear = new Map(); for (const p of points) { const yr = Math.floor(p.year); - const entry = byYear.get(yr); - if (entry) { - entry.sum += p.price; - entry.count += 1; - } else { - byYear.set(yr, { sum: p.price, count: 1 }); - } + const arr = byYear.get(yr); + if (arr) arr.push(p.price); + else byYear.set(yr, [p.price]); } - const avgs = Array.from(byYear.entries()) - .map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count })) + const meds = Array.from(byYear.entries()) + .map(([yr, prices]) => { + prices.sort((a, b) => a - b); + const mid = Math.floor(prices.length / 2); + const median = + prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2; + return { year: yr + 0.5, price: median }; + }) .sort((a, b) => a.year - b.year); - // Price ticks (3-5 nice round numbers) const ticks = niceTicksForRange(pMin, pMax, 4); - return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks }; + return { + yearMin: yMin, + yearMax: yMax, + priceMin: pMin, + priceMax: pMax, + medians: meds, + priceTicks: ticks, + }; }, [points]); - const scaleY = (price: number) => { - const ratio = (price - priceMin) / (priceMax - priceMin || 1); - return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom); - }; - + const plotW = width - PADDING.left - PADDING.right; + const plotH = HEIGHT - PADDING.top - PADDING.bottom; const yearRange = yearMax - yearMin || 1; + const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW; + const scaleY = (price: number) => { + const t = (price - priceMin) / (priceMax - priceMin || 1); + return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH; + }; + // Year labels: every 5 years const yearStart = Math.ceil(yearMin / 5) * 5; const yearLabels: number[] = []; for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y); - const VB_W = 1000; - - const scaleX = (year: number) => { - const ratio = (year - yearMin) / yearRange; - return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right); - }; - - const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' '); + const medianPolyline = medians + .map((a) => `${scaleX(a.year)},${scaleY(a.price)}`) + .join(' '); return ( - - {/* Grid lines */} - {priceTicks.map((tick) => ( - - ))} +
+ {width > 0 && ( + + {/* Grid lines */} + {priceTicks.map((tick) => ( + + ))} - {/* Dots */} - {points.map((p, i) => ( - - ))} + {/* Dots (clamp outliers to visible range) */} + {points.map((p, i) => ( + + ))} - {/* Average line */} - {averages.length > 1 && ( - + {/* Median line */} + {medians.length > 1 && ( + + )} + + {/* Y-axis labels */} + {priceTicks.map((tick) => ( + + {formatValue(tick, priceFmt)} + + ))} + + {/* X-axis year labels */} + {yearLabels.map((yr) => ( + + {yr} + + ))} + )} - - {/* Y-axis labels */} - {priceTicks.map((tick) => ( - - {formatValue(tick)} - - ))} - - {/* X-axis year labels */} - {yearLabels.map((yr) => ( - - {yr} - - ))} - +
); } @@ -151,7 +169,6 @@ function niceTicksForRange(min: number, max: number, count: number): number[] { const range = max - min; if (range <= 0) return [min]; const rough = range / count; - // Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc. const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); let step: number; const normalized = rough / magnitude; diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx index 483da91..a8aeb0b 100644 --- a/frontend/src/components/map/PropertiesPane.tsx +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -17,8 +17,6 @@ interface PropertiesPaneProps { onNavigateToSource?: (slug: string) => void; } -type SortBy = 'price' | 'size' | 'energy'; - export function PropertiesPane({ properties, total, @@ -28,30 +26,19 @@ export function PropertiesPane({ onClose, onNavigateToSource, }: PropertiesPaneProps) { - const [sortBy, setSortBy] = useState('price'); const [search, setSearch] = useState(''); const [showInfo, setShowInfo] = useState(false); - const filteredAndSorted = useMemo(() => { + const filtered = useMemo(() => { const query = search.trim().toLowerCase(); - const filtered = query + return query ? properties.filter((p) => { const addr = (p.address || '').toLowerCase(); const pc = (p.postcode || '').toLowerCase(); return addr.includes(query) || pc.includes(query); }) : properties; - return [...filtered].sort((a, b) => { - switch (sortBy) { - case 'price': - return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0); - case 'size': - return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0); - case 'energy': - return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z'); - } - }); - }, [properties, sortBy, search]); + }, [properties, search]); if (!hexagonId) { return ( @@ -91,22 +78,13 @@ export function PropertiesPane({ )} -
+
-
@@ -114,7 +92,7 @@ export function PropertiesPane({
Loading...
) : ( <> - {filteredAndSorted.map((property, idx) => ( + {filtered.map((property, idx) => ( ))} {properties.length < total && ( diff --git a/frontend/src/components/map/StackedBarChart.tsx b/frontend/src/components/map/StackedBarChart.tsx index c793cb7..548b103 100644 --- a/frontend/src/components/map/StackedBarChart.tsx +++ b/frontend/src/components/map/StackedBarChart.tsx @@ -10,6 +10,8 @@ interface Segment { interface StackedBarChartProps { segments: Segment[]; total: number; + /** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */ + colorMap?: Record; } /** Strip common suffixes/prefixes to produce short legend labels */ @@ -26,7 +28,7 @@ function shortenLabel(name: string): string { .trim(); } -export default function StackedBarChart({ segments, total }: StackedBarChartProps) { +export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) { const sortedSegments = useMemo( () => [...segments].sort((a, b) => b.value - a.value), [segments] @@ -51,7 +53,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp className="h-full" style={{ width: `${pct}%`, - backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length], + backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length], }} title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`} /> @@ -66,7 +68,7 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp diff --git a/frontend/src/components/map/StackedEnumChart.tsx b/frontend/src/components/map/StackedEnumChart.tsx new file mode 100644 index 0000000..4ecffdb --- /dev/null +++ b/frontend/src/components/map/StackedEnumChart.tsx @@ -0,0 +1,78 @@ +import type { EnumFeatureStats } from '../../types'; + +interface StackedEnumChartProps { + components: { label: string; stats: EnumFeatureStats }[]; + valueOrder: string[]; + valueColors: string[]; +} + +/** Strip common suffixes to produce short row labels */ +function shortenLabel(name: string): string { + return name.replace(/ risk$/, ''); +} + +export default function StackedEnumChart({ + components, + valueOrder, + valueColors, +}: StackedEnumChartProps) { + const visibleRows = components.filter(({ stats }) => { + const total = Object.values(stats.counts).reduce((a, b) => a + b, 0); + if (total === 0) return false; + const lowCount = stats.counts[valueOrder[0]] ?? 0; + return total - lowCount > 0; + }); + + if (visibleRows.length === 0) { + return ( +
All low
+ ); + } + + return ( +
+ {visibleRows.map(({ label, stats }) => { + const total = Object.values(stats.counts).reduce((a, b) => a + b, 0); + + return ( +
+ + {shortenLabel(label)} + +
+ {valueOrder.map((value, i) => { + const count = stats.counts[value] ?? 0; + const pct = (count / total) * 100; + if (pct < 0.5) return null; + return ( +
+ ); + })} +
+
+ ); + })} + + {/* Legend */} +
+ {valueOrder.map((value, i) => ( +
+ + {value} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx new file mode 100644 index 0000000..b1df526 --- /dev/null +++ b/frontend/src/components/ui/AuthModal.tsx @@ -0,0 +1,154 @@ +import { useState, useCallback } from 'react'; +import { CloseIcon } from './icons/CloseIcon'; + +type Tab = 'login' | 'register'; + +export default function AuthModal({ + onClose, + onLogin, + onRegister, + loading, + error, + onClearError, +}: { + onClose: () => void; + onLogin: (email: string, password: string) => Promise; + onRegister: (email: string, password: string, name?: string) => Promise; + loading: boolean; + error: string | null; + onClearError: () => void; +}) { + const [tab, setTab] = useState('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + + const switchTab = useCallback( + (newTab: Tab) => { + setTab(newTab); + onClearError(); + }, + [onClearError] + ); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (tab === 'login') { + await onLogin(email, password); + } else { + await onRegister(email, password, name || undefined); + } + onClose(); + } catch { + // Error is handled by the hook + } + }, + [tab, email, password, name, onLogin, onRegister, onClose] + ); + + return ( +
+
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {tab === 'login' ? 'Log in' : 'Create account'} +

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Form */} +
+ {tab === 'register' && ( +
+ + 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-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400" + placeholder="Your name (optional)" + /> +
+ )} + +
+ + setEmail(e.target.value)} + required + 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-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + 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-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400" + placeholder={tab === 'register' ? 'Min 8 characters' : 'Your password'} + /> +
+ + {error &&

{error}

} + + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 73a73f2..70278f5 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -17,8 +17,8 @@ export function FeatureLabel({ const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; return ( -
- +
+ {feature.name} {feature.detail && onShowInfo && ( diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 6549052..18a633a 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -1,4 +1,12 @@ import { useState, useCallback } from 'react'; +import type { AuthUser } from '../../hooks/useAuth'; +import { DownloadIcon } from './icons/DownloadIcon'; +import { MapPinIcon } from './icons/MapPinIcon'; +import { CheckIcon } from './icons/CheckIcon'; +import { ClipboardIcon } from './icons/ClipboardIcon'; +import { SunIcon } from './icons/SunIcon'; +import { MoonIcon } from './icons/MoonIcon'; +import UserMenu from './UserMenu'; export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq'; @@ -7,11 +15,21 @@ export default function Header({ onPageChange, theme, onToggleTheme, + onExport, + exporting, + user, + onLoginClick, + onLogout, }: { activePage: Page; onPageChange: (page: Page) => void; theme: 'light' | 'dark'; onToggleTheme: () => void; + onExport: (() => void) | null; + exporting: boolean; + user: AuthUser | null; + onLoginClick: () => void; + onLogout: () => void; }) { const [copied, setCopied] = useState(false); @@ -36,25 +54,7 @@ export default function Header({ className="flex items-center gap-2 hover:opacity-80 transition-opacity" onClick={() => onPageChange('home')} > - - - - + Narrowit
+ {activePage === 'dashboard' && ( + <> + + + + )} + {user ? ( + + ) : ( + + )} - {activePage === 'dashboard' && ( - - )}
); diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx new file mode 100644 index 0000000..19d75ad --- /dev/null +++ b/frontend/src/components/ui/UserMenu.tsx @@ -0,0 +1,57 @@ +import { useState, useRef, useEffect } from 'react'; +import type { AuthUser } from '../../hooks/useAuth'; + +export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const initial = (user.name || user.email)[0].toUpperCase(); + + return ( +
+ + + {open && ( +
+
+ {user.name && ( +

+ {user.name} +

+ )} +

{user.email}

+
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/icons/CheckIcon.tsx b/frontend/src/components/ui/icons/CheckIcon.tsx new file mode 100644 index 0000000..60cfaf0 --- /dev/null +++ b/frontend/src/components/ui/icons/CheckIcon.tsx @@ -0,0 +1,11 @@ +interface IconProps { + className?: string; +} + +export function CheckIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/icons/ClipboardIcon.tsx b/frontend/src/components/ui/icons/ClipboardIcon.tsx new file mode 100644 index 0000000..6ea6915 --- /dev/null +++ b/frontend/src/components/ui/icons/ClipboardIcon.tsx @@ -0,0 +1,15 @@ +interface IconProps { + className?: string; +} + +export function ClipboardIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/icons/DownloadIcon.tsx b/frontend/src/components/ui/icons/DownloadIcon.tsx new file mode 100644 index 0000000..5e88ea4 --- /dev/null +++ b/frontend/src/components/ui/icons/DownloadIcon.tsx @@ -0,0 +1,12 @@ +interface IconProps { + className?: string; +} + +export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/ui/icons/MapPinIcon.tsx b/frontend/src/components/ui/icons/MapPinIcon.tsx new file mode 100644 index 0000000..315dd2e --- /dev/null +++ b/frontend/src/components/ui/icons/MapPinIcon.tsx @@ -0,0 +1,20 @@ +interface IconProps { + className?: string; +} + +export function MapPinIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/ui/icons/MoonIcon.tsx b/frontend/src/components/ui/icons/MoonIcon.tsx new file mode 100644 index 0000000..3c5cdea --- /dev/null +++ b/frontend/src/components/ui/icons/MoonIcon.tsx @@ -0,0 +1,15 @@ +interface IconProps { + className?: string; +} + +export function MoonIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/icons/SpinnerIcon.tsx b/frontend/src/components/ui/icons/SpinnerIcon.tsx new file mode 100644 index 0000000..631f395 --- /dev/null +++ b/frontend/src/components/ui/icons/SpinnerIcon.tsx @@ -0,0 +1,12 @@ +interface IconProps { + className?: string; +} + +export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/ui/icons/SunIcon.tsx b/frontend/src/components/ui/icons/SunIcon.tsx new file mode 100644 index 0000000..bb22a54 --- /dev/null +++ b/frontend/src/components/ui/icons/SunIcon.tsx @@ -0,0 +1,15 @@ +interface IconProps { + className?: string; +} + +export function SunIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..677c31c --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,108 @@ +import { useState, useEffect, useCallback } from 'react'; +import pb from '../lib/pocketbase'; + +export interface AuthUser { + id: string; + email: string; + name: string; + avatar: string; + verified: boolean; +} + +// PocketBase RecordModel stores user fields as dynamic properties +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function recordToUser(record: any): AuthUser { + return { + id: record.id || '', + email: record.email || '', + name: record.name || '', + avatar: record.avatar || '', + verified: record.verified || false, + }; +} + +export function useAuth() { + const [user, setUser] = useState(() => { + if (pb.authStore.isValid && pb.authStore.record) { + return recordToUser(pb.authStore.record); + } + return null; + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Sync with authStore changes (cross-tab, external updates) + useEffect(() => { + const unsubscribe = pb.authStore.onChange(() => { + if (pb.authStore.isValid && pb.authStore.record) { + setUser(recordToUser(pb.authStore.record)); + } else { + setUser(null); + } + }); + return unsubscribe; + }, []); + + const login = useCallback(async (email: string, password: string) => { + setLoading(true); + setError(null); + try { + const result = await pb.collection('users').authWithPassword(email, password); + setUser(recordToUser(result.record)); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Login failed'; + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const register = useCallback(async (email: string, password: string, name?: string) => { + setLoading(true); + setError(null); + try { + await pb.collection('users').create({ + email, + password, + passwordConfirm: password, + name: name || '', + }); + // Auto-login after registration + const result = await pb.collection('users').authWithPassword(email, password); + setUser(recordToUser(result.record)); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Registration failed'; + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const loginWithOAuth = useCallback(async (provider: string) => { + setLoading(true); + setError(null); + try { + const result = await pb.collection('users').authWithOAuth2({ provider }); + setUser(recordToUser(result.record)); + } catch (err) { + const msg = err instanceof Error ? err.message : 'OAuth login failed'; + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const logout = useCallback(() => { + pb.authStore.clear(); + setUser(null); + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + return { user, loading, error, login, register, loginWithOAuth, logout, clearError }; +} diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts index dbd8cec..6e80358 100644 --- a/frontend/src/hooks/useHexagonSelection.ts +++ b/frontend/src/hooks/useHexagonSelection.ts @@ -8,7 +8,7 @@ import type { HexagonStatsResponse, NumericFeatureStats, } from '../types'; -import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; +import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api'; interface SelectedHexagon { id: string; @@ -50,7 +50,7 @@ export function useHexagonSelection({ if (fields) { params.set('fields', fields.join(',')); } - const response = await fetch(apiUrl('hexagon-stats', params), { signal }); + const response = await fetch(apiUrl('hexagon-stats', params), authHeaders({ signal })); return (await response.json()) as HexagonStatsResponse; }, [filters, features] @@ -96,7 +96,7 @@ export function useHexagonSelection({ const filterStr = buildFilterString(filters, features); if (filterStr) params.append('filters', filterStr); - const response = await fetch(apiUrl('hexagon-properties', params)); + const response = await fetch(apiUrl('hexagon-properties', params), authHeaders()); const data: HexagonPropertiesResponse = await response.json(); if (offset === 0) { diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 14945a2..8f9f019 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -8,7 +8,7 @@ import type { ViewChangeParams, ApiResponse, } from '../types'; -import { buildFilterString, apiUrl, logNonAbortError } from '../lib/api'; +import { buildFilterString, apiUrl, logNonAbortError, authHeaders } from '../lib/api'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/map-utils'; const DEBOUNCE_MS = 150; @@ -76,9 +76,12 @@ export function useMapData({ const params = new URLSearchParams({ bounds: boundsStr }); if (filtersStr) params.set('filters', filtersStr); params.set('fields', viewFeature || ''); - const res = await fetch(apiUrl('postcodes', params), { - signal: abortControllerRef.current.signal, - }); + const res = await fetch( + apiUrl('postcodes', params), + authHeaders({ + signal: abortControllerRef.current.signal, + }) + ); const json: { features: PostcodeFeature[] } = await res.json(); setPostcodeData(json.features || []); setRawData([]); @@ -89,9 +92,12 @@ export function useMapData({ }); if (filtersStr) params.set('filters', filtersStr); params.set('fields', viewFeature || ''); - const res = await fetch(apiUrl('hexagons', params), { - signal: abortControllerRef.current.signal, - }); + const res = await fetch( + apiUrl('hexagons', params), + authHeaders({ + signal: abortControllerRef.current.signal, + }) + ); const json: ApiResponse = await res.json(); setRawData(json.features || []); setPostcodeData([]); @@ -162,7 +168,13 @@ export function useMapData({ }, [viewFeature, features, dataRange, activeFeature, dragValue]); const handleViewChange = useCallback( - ({ resolution: newRes, bounds: newBounds, zoom: newZoom, latitude, longitude }: ViewChangeParams) => { + ({ + resolution: newRes, + bounds: newBounds, + zoom: newZoom, + latitude, + longitude, + }: ViewChangeParams) => { const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; if (boundsKey !== prevBoundsRef.current) { prevBoundsRef.current = boundsKey; @@ -175,10 +187,13 @@ export function useMapData({ [] ); - const setInitialView = useCallback((view: { latitude: number; longitude: number; zoom: number }) => { - setCurrentView(view); - setZoom(view.zoom); - }, []); + const setInitialView = useCallback( + (view: { latitude: number; longitude: number; zoom: number }) => { + setCurrentView(view); + setZoom(view.zoom); + }, + [] + ); return { data, diff --git a/frontend/src/hooks/usePOIData.ts b/frontend/src/hooks/usePOIData.ts index 5dcedfd..8388f9e 100644 --- a/frontend/src/hooks/usePOIData.ts +++ b/frontend/src/hooks/usePOIData.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import type { Bounds, POI, POIResponse } from '../types'; -import { apiUrl, logNonAbortError } from '../lib/api'; +import { apiUrl, logNonAbortError, authHeaders } from '../lib/api'; const DEBOUNCE_MS = 150; @@ -32,9 +32,12 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set = {}; + if (pb.authStore.isValid && pb.authStore.token) { + headers['Authorization'] = `Bearer ${pb.authStore.token}`; + } + if (!init) return { headers }; + const existing = init.headers as Record | undefined; + return { ...init, headers: { ...existing, ...headers } }; } -// API URL helper export function apiUrl(endpoint: string, params?: URLSearchParams): string { - const base = getApiBaseUrl(); const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`; const query = params?.toString(); - return query ? `${base}${path}?${query}` : `${base}${path}`; + return query ? `${path}?${query}` : path; } export async function fetchWithRetry( @@ -30,7 +34,7 @@ export async function fetchWithRetry( let delay = INITIAL_RETRY_MS; while (!signal.aborted) { try { - const res = await fetch(url, { signal }); + const res = await fetch(url, authHeaders({ signal })); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); onSuccess(json); @@ -44,26 +48,6 @@ export async function fetchWithRetry( } } -export function getApiBaseUrl(): string { - if (process.env.NODE_ENV === 'production') { - return ''; - } - - const { pathname, href } = window.location; - - const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/); - if (pathMatch) { - return `${pathMatch[1]}8001`; - } - - const hrefMatch = href.match(/(\/proxy\/)\d+/); - if (hrefMatch) { - return `${hrefMatch[1]}8001`; - } - - return ''; -} - export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string { const entries = Object.entries(filters); if (entries.length === 0) return ''; diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 2b327b6..1a8f650 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -1,12 +1,14 @@ import type { ViewState } from '../types'; + +export const INITIAL_RETRY_MS = 1000; +export const MAX_RETRY_MS = 10000; + + export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57]; export const MAP_MIN_ZOOM = 5.5; -/** Maximum zoom level for tile fetching (map extrapolates beyond this) */ -export const TILE_MAX_ZOOM = 15; -/** Initial map view state */ export const INITIAL_VIEW_STATE: ViewState = { longitude: -1.5, latitude: 53.5, @@ -27,14 +29,11 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [ { maxZoom: 13, resolution: 9 }, { maxZoom: Infinity, resolution: 10 }, ] as const; -export const POSTCODE_ZOOM_THRESHOLD = 15; + +export const POSTCODE_ZOOM_THRESHOLD = 17.5; -// ============================================================================= -// Color Gradients -// ============================================================================= -/** Feature value gradient (green → yellow → red → purple) */ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ { t: 0, color: [46, 204, 113] }, { t: 0.33, color: [241, 196, 15] }, @@ -42,34 +41,37 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] { t: 1, color: [142, 68, 173] }, ]; -/** Property density gradient (teal → blue → purple) */ +/** Property density gradient — light mode (cream → orange) */ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [ - { t: 0, color: [130, 234, 220] }, - { t: 0.5, color: [20, 140, 180] }, - { t: 1, color: [88, 28, 140] }, + { t: 0, color: [255, 255, 255] }, + { t: 0.1, color: [248, 233, 211] }, + { t: 0.5, color: [255, 221, 173] }, + { t: 0.8, color: [251, 171, 60] }, + { t: 1, color: [255, 162, 31] }, ]; -// ============================================================================= -// External URLs -// ============================================================================= +/** Property density gradient — dark mode (dark warm → bright amber) */ +export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [ + { t: 0, color: [55, 45, 35] }, + { t: 0.1, color: [85, 65, 40] }, + { t: 0.5, color: [170, 115, 50] }, + { t: 0.8, color: [230, 155, 45] }, + { t: 1, color: [255, 170, 40] }, +]; -/** Protomaps font glyphs URL */ -export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf'; +/** Protomaps font glyphs URL (served locally from public/assets/) */ +export const GLYPHS_URL = '/assets/fonts/{fontstack}/{range}.pbf'; -/** Protomaps sprite base URL */ -export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4'; +/** Twemoji base URL (served locally from public/assets/) */ +export const TWEMOJI_BASE = '/assets/twemoji/'; -/** Twemoji CDN base URL */ -export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/'; -/** OpenStreetMap attribution HTML */ -export const OSM_ATTRIBUTION = '© OpenStreetMap'; -// ============================================================================= -// Stacked Chart Groups -// ============================================================================= - -export interface StackedChartConfig { +/** + * Groups whose features should be collapsed into stacked bar charts. + * Keyed by feature group name. Each entry defines one stacked chart. + */ +export const STACKED_GROUPS: Record = { +}[]> = { Crime: [ { label: 'Serious crime', @@ -124,6 +120,56 @@ export const STACKED_GROUPS: Record = { ], }; +/** + * Groups whose enum features should be collapsed into compact multi-row charts. + * Keyed by feature group name. Each entry defines one stacked enum chart. + */ +export const STACKED_ENUM_GROUPS: Record = { + Property: [ + { + label: 'Property type', + feature: 'Property type', + components: ['Property type'], + valueOrder: ['Detached', 'Semi-Detached', 'Terraced', 'Flat'], + valueColors: ['#8b5cf6', '#3b82f6', '#14b8a6', '#f59e0b'], + }, + { + label: 'Leasehold/Freehold', + feature: 'Leashold/Freehold', + components: ['Leashold/Freehold'], + valueOrder: ['Freehold', 'Leasehold'], + valueColors: ['#3b82f6', '#f59e0b'], + }, + ], + Environment: [ + { + label: 'Ground Risk', + feature: 'Environmental risk', + components: [ + 'Collapsible deposits risk', + 'Compressible ground risk', + 'Landslide risk', + 'Running sand risk', + 'Shrink-swell risk', + 'Soluble rocks risk', + ], + valueOrder: ['Low', 'Moderate', 'Significant'], + valueColors: ['#22c55e', '#eab308', '#ef4444'], + }, + ], +}; + /** Colors for stacked bar segments */ export const SEGMENT_COLORS = [ '#ef4444', // red-500 diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9d8d0ba..b039393 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -1,10 +1,62 @@ -export function formatValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - if (Number.isInteger(value)) return value.toLocaleString(); - return value.toFixed(1); +export interface ValueFormat { + prefix?: string; + suffix?: string; + /** Show full integer (no k/M abbreviation) */ + raw?: boolean; } +export function formatValue(value: number, fmt?: ValueFormat): string { + const p = fmt?.prefix ?? ''; + const s = fmt?.suffix ?? ''; + if (fmt?.raw) return `${p}${Math.round(value)}${s}`; + if (Math.abs(value) >= 1_000_000) return `${p}${(value / 1_000_000).toFixed(1)}M${s}`; + if (Math.abs(value) >= 1_000) return `${p}${(value / 1_000).toFixed(1)}k${s}`; + if (Number.isInteger(value)) return `${p}${value.toLocaleString()}${s}`; + return `${p}${value.toFixed(1)}${s}`; +} + +/** Lookup table for feature-specific formatting */ +export const FEATURE_FORMATS: Record = { + // Property + 'Last known price': { prefix: '£' }, + 'Price per sqm': { prefix: '£' }, + 'Total floor area (sqm)': { suffix: ' sqm' }, + 'Number of bedrooms & living rooms': { suffix: ' rooms' }, + 'Transaction year': { raw: true }, + 'Construction age': { raw: true }, + // Transport + 'Public transport to Bank (mins)': { suffix: ' mins' }, + 'Public transport to Fitzrovia (mins)': { suffix: ' mins' }, + 'Cycling to Bank (mins)': { suffix: ' mins' }, + 'Cycling to Fitzrovia (mins)': { suffix: ' mins' }, + // Crime + 'Anti-social behaviour (avg/yr)': { suffix: '/yr' }, + 'Violence and sexual offences (avg/yr)': { suffix: '/yr' }, + 'Criminal damage and arson (avg/yr)': { suffix: '/yr' }, + 'Burglary (avg/yr)': { suffix: '/yr' }, + 'Vehicle crime (avg/yr)': { suffix: '/yr' }, + 'Robbery (avg/yr)': { suffix: '/yr' }, + 'Other theft (avg/yr)': { suffix: '/yr' }, + 'Shoplifting (avg/yr)': { suffix: '/yr' }, + 'Drugs (avg/yr)': { suffix: '/yr' }, + 'Possession of weapons (avg/yr)': { suffix: '/yr' }, + 'Public order (avg/yr)': { suffix: '/yr' }, + 'Bicycle theft (avg/yr)': { suffix: '/yr' }, + 'Theft from the person (avg/yr)': { suffix: '/yr' }, + 'Other crime (avg/yr)': { suffix: '/yr' }, + 'Serious crime (avg/yr)': { suffix: '/yr' }, + 'Minor crime (avg/yr)': { suffix: '/yr' }, + // Demographics + '% White': { suffix: '%' }, + '% Asian': { suffix: '%' }, + '% Black': { suffix: '%' }, + '% Mixed': { suffix: '%' }, + '% Other': { suffix: '%' }, + // Environment + 'Noise (dB)': { suffix: ' dB' }, + 'Max available download speed (Mbps)': { suffix: ' Mbps', raw: true }, +}; + export function formatFilterValue(value: number): string { if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; @@ -29,20 +81,31 @@ export function formatNumber(value: number | undefined, decimals = 0): string { return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString(); } -// Calculate weighted mean from histogram +// Calculate weighted mean from histogram with outlier bins. +// Bin 0 = [min, p1), bins 1..n-2 = [p1, p99) evenly, bin n-1 = [p99, max]. export function calculateHistogramMean(histogram: { min: number; - bin_width: number; + max: number; + p1: number; + p99: number; counts: number[]; }): number | undefined { - if (!histogram.counts.length) return undefined; + const n = histogram.counts.length; + if (n === 0) return undefined; const totalCount = histogram.counts.reduce((a, b) => a + b, 0); if (totalCount === 0) return undefined; + const { min, max, p1, p99 } = histogram; + const middleBins = Math.max(n - 2, 0); + const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; + let weightedSum = 0; - for (let i = 0; i < histogram.counts.length; i++) { - const binCenter = histogram.min + (i + 0.5) * histogram.bin_width; - weightedSum += binCenter * histogram.counts[i]; + for (let i = 0; i < n; i++) { + let center: number; + if (i === 0) center = (min + p1) / 2; + else if (i === n - 1) center = (p99 + max) / 2; + else center = p1 + (i - 0.5) * middleWidth; + weightedSum += center * histogram.counts[i]; } return weightedSum / totalCount; } diff --git a/frontend/src/lib/map-utils.ts b/frontend/src/lib/map-utils.ts index d8853c6..ec054de 100644 --- a/frontend/src/lib/map-utils.ts +++ b/frontend/src/lib/map-utils.ts @@ -3,40 +3,94 @@ import type { StyleSpecification } from 'maplibre-gl'; import { layers, namedFlavor } from '@protomaps/basemaps'; import { GLYPHS_URL, - SPRITE_URL_BASE, - TILE_MAX_ZOOM, - OSM_ATTRIBUTION, FEATURE_GRADIENT, DENSITY_GRADIENT, + DENSITY_GRADIENT_DARK, ZOOM_TO_RESOLUTION_THRESHOLDS, TWEMOJI_BASE, + POSTCODE_ZOOM_THRESHOLD, } from './consts'; // Re-export constants for backwards compatibility -export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts'; +export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, POSTCODE_ZOOM_THRESHOLD } from './consts'; + +const ROAD_OPACITY = 0.4; export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification { const flavor = namedFlavor(theme); // Use absolute URL for tiles - required by MapLibre const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`; + const baseLayers = layers('protomaps', flavor, { lang: 'en' }); + + // Reduce road layer opacity so hexagons are more visible + const modifiedLayers = baseLayers.map((layer) => { + if (layer.id.includes('roads_') || layer.id.includes('road_')) { + if (layer.type === 'line') { + return { ...layer, paint: { ...layer.paint, 'line-opacity': ROAD_OPACITY } }; + } else if (layer.type === 'fill') { + return { ...layer, paint: { ...layer.paint, 'fill-opacity': ROAD_OPACITY } }; + } + } + return layer; + }); + return { version: 8, glyphs: GLYPHS_URL, - sprite: `${SPRITE_URL_BASE}/${theme}`, sources: { protomaps: { type: 'vector', tiles: [tileUrl], - maxzoom: TILE_MAX_ZOOM, - attribution: OSM_ATTRIBUTION, + maxzoom: POSTCODE_ZOOM_THRESHOLD, }, }, - layers: layers('protomaps', flavor, { lang: 'en' }), + layers: modifiedLayers, } as StyleSpecification; } type GradientStop = { t: number; color: [number, number, number] }; +// Oklab color space for perceptually uniform interpolation +function srgbToLinear(c: number): number { + const v = c / 255; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); +} + +function linearToSrgb(c: number): number { + const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + return Math.round(Math.max(0, Math.min(255, v * 255))); +} + +function rgbToOklab(rgb: [number, number, number]): [number, number, number] { + const r = srgbToLinear(rgb[0]); + const g = srgbToLinear(rgb[1]); + const b = srgbToLinear(rgb[2]); + + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + + return [ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ]; +} + +function oklabToRgb(lab: [number, number, number]): [number, number, number] { + const L = lab[0], a = lab[1], b = lab[2]; + + const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3); + const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3); + const s = Math.pow(L - 0.0894841775 * a - 1.2914855480 * b, 3); + + return [ + linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), + ]; +} + function interpolateGradient(t: number, gradient: GradientStop[]): [number, number, number] { if (t <= 0) return gradient[0].color; if (t >= 1) return gradient[gradient.length - 1].color; @@ -46,11 +100,14 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb const hi = gradient[i + 1]; if (t >= lo.t && t <= hi.t) { const frac = (t - lo.t) / (hi.t - lo.t); - return [ - Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), - Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), - Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), + const loLab = rgbToOklab(lo.color); + const hiLab = rgbToOklab(hi.color); + const interpLab: [number, number, number] = [ + loLab[0] + (hiLab[0] - loLab[0]) * frac, + loLab[1] + (hiLab[1] - loLab[1]) * frac, + loLab[2] + (hiLab[2] - loLab[2]) * frac, ]; + return oklabToRgb(interpLab); } } return gradient[gradient.length - 1].color; @@ -60,8 +117,8 @@ export function normalizedToColor(t: number): [number, number, number] { return interpolateGradient(t, FEATURE_GRADIENT); } -export function countToColor(t: number): [number, number, number] { - return interpolateGradient(t, DENSITY_GRADIENT); +export function countToColor(t: number, gradient: GradientStop[] = DENSITY_GRADIENT): [number, number, number] { + return interpolateGradient(t, gradient); } export function zoomToResolution(zoom: number): number { diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts new file mode 100644 index 0000000..d5f0cf0 --- /dev/null +++ b/frontend/src/lib/pocketbase.ts @@ -0,0 +1,5 @@ +import PocketBase from 'pocketbase'; + +const pb = new PocketBase('/pb'); + +export default pb; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..6733231 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,7 @@ +/** + * Converts a gradient definition to CSS linear-gradient string + */ +export function gradientToCss(gradient: { t: number; color: [number, number, number] }[]): string { + const stops = gradient.map(({ t, color }) => `rgb(${color.join(',')}) ${t * 100}%`).join(', '); + return `linear-gradient(in oklch to right, ${stops})`; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d5ec33f..f54c2f8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -6,7 +6,7 @@ export interface FeatureMeta { min?: number; max?: number; step?: number; - histogram?: { min: number; max: number; bin_width: number; counts: number[] }; + histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] }; // Enum-only fields values?: string[]; // Description fields @@ -124,7 +124,7 @@ export interface NumericFeatureStats { min: number; max: number; mean: number; - histogram?: { min: number; max: number; bin_width: number; counts: number[] }; + histogram?: { min: number; max: number; p1: number; p99: number; counts: number[] }; } export interface EnumFeatureStats {