Lots of frontend changes

This commit is contained in:
Andras Schmelczer 2026-02-07 19:10:53 +00:00
parent ec29631c44
commit 555ba7cf53
38 changed files with 1508 additions and 648 deletions

View file

@ -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<ExportState | null>(null);
if (isScreenshotMode) {
return (
<MapPage
@ -142,6 +156,11 @@ export default function App() {
onPageChange={navigateTo}
theme={theme}
onToggleTheme={toggleTheme}
onExport={exportState?.onExport ?? null}
exporting={exportState?.exporting ?? false}
user={user}
onLoginClick={() => setShowAuthModal(true)}
onLogout={logout}
/>
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
@ -162,6 +181,17 @@ export default function App() {
pendingInfoFeature={pendingInfoFeature}
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
/>
)}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
loading={authLoading}
error={authError}
onClearError={clearError}
/>
)}
</div>

View file

@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void })
return (
<button
onClick={onNavigate}
className="absolute bottom-2 right-2 bg-white/90 dark:bg-navy-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
className="absolute bottom-2 right-2 bg-white/90 dark:bg-warm-800/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline font-semibold transition-colors"
>
Data Sources
</button>

View file

@ -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)}
>
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
<svg
<ChevronIcon
direction="down"
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
/>
</button>
{open && (
<div className="px-5 pb-4">

View file

@ -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 (
<div key={group.name}>
@ -183,75 +192,157 @@ export default function AreaPane({
</div>
);
})
: // 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 (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)}
</span>
</div>
{numericStats.histogram && (
<>
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
<span>{formatValue(numericStats.histogram.min)}</span>
<span>{formatValue(numericStats.histogram.max)}</span>
</div>
{globalHistogram ? (
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])}
</span>
</div>
{numericStats.histogram && (
globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
min={numericStats.histogram.min}
max={numericStats.histogram.max}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={formatFilterValue}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
min={numericStats.histogram.min}
max={numericStats.histogram.max}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={formatFilterValue}
/>
)}
</>
)}
</div>
);
}
)
)}
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
if (enumStats) {
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart counts={enumStats.counts} />
</div>
);
}
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 (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{chart.label}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
}
// 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<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={chart.label}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: chart.label }}
onShowInfo={setInfoFeature}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{chart.label}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div>
</div>
);

View file

@ -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<number, string>(); // 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 (
<div className="mt-1">
@ -56,13 +105,26 @@ export function DualHistogram({
</div>
);
})}
{meanFraction != null && meanFraction >= 0 && meanFraction <= 1 && (
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
<div
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
style={{ left: `${meanFraction * 100}%` }}
style={{ left: `${meanPct}%` }}
/>
)}
</div>
{tickBars.size > 0 && (
<div className="flex gap-px mt-0.5">
{Array.from({ length: barCount }).map((_, index) => (
<div key={index} className="flex-1 min-w-[2px] text-center">
{tickBars.has(index) && (
<span className="text-[9px] leading-none text-warm-400 dark:text-warm-500">
{tickBars.get(index)}
</span>
)}
</div>
))}
</div>
)}
</div>
);
}

View file

@ -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<FeatureMeta | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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 (
<>
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="flex-1 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0">
{group.name}
</div>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
<div className="min-h-0 flex-1 overflow-y-auto flex flex-col">
{grouped.map((group) => {
const isExpanded = isSearching || expandedGroups.has(group.name);
return (
<div key={group.name} className="shrink-0">
<button
onClick={() => toggleGroup(group.name)}
className="w-full flex items-center justify-between px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
>
<span>{group.name}</span>
<div className="flex items-center gap-1">
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
<ChevronIcon direction={isExpanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
</div>
);
})}
</div>
))}
{grouped.length === 0 && (
</button>
{isExpanded &&
group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} onShowInfo={setInfoFeature} size="sm" />
{f.description && (
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
{f.description}
</span>
)}
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onAdd={onAddFilter}
/>
</div>
);
})}
</div>
);
})}
{grouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
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"
/>
) : (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
)}
</div>
{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<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [splitFraction, setSplitFraction] = useState(0.65);
const draggingRef = useRef(false);
const [showPhilosophy, setShowPhilosophy] = useState(false);
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(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 (
<div
ref={containerRef}
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
>
<div ref={headerRef} className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex flex-col bg-white dark:bg-navy-950 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)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
@ -195,7 +198,7 @@ export default memo(function Filters({
Finding the Perfect Postcode
</button>
</div>
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
<div className="min-h-0 flex flex-col 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">
@ -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' : ''}`}
>
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
</span>
</div>
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])}
</span>
<FeatureActions
feature={feature}
isPinned={isPinned}
@ -308,16 +309,7 @@ export default memo(function Filters({
</div>
</div>
<div
className="shrink-0 h-1.5 cursor-row-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-y border-warm-200 dark:border-navy-700"
onPointerDown={handleSeparatorPointerDown}
onPointerMove={handleSeparatorPointerMove}
onPointerUp={handleSeparatorPointerUp}
>
<div className="w-8 h-0.5 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="min-h-0 flex-1 flex flex-col">
<div className="min-h-0 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>

View file

@ -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}
/>
) : (
<MapLegend
@ -578,6 +591,7 @@ export default memo(function Map({
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
/>
)}
{popupInfo && (

View file

@ -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 (
<div className="absolute top-3 right-3 z-10 bg-white dark:bg-navy-800 dark:text-warm-200 rounded shadow-lg p-3 text-xs min-w-[160px]">
@ -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"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<CloseIcon className="w-4 h-4" />
</button>
)}
</div>

View file

@ -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 (
<div className="flex-1 flex overflow-hidden relative">
{initialLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4">
<svg className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<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>

View file

@ -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<Set<string>>(new Set());
const [showInfo, setShowInfo] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon />
</IconButton>
</div>
<div className="flex flex-col h-full bg-white dark:bg-navy-950 shadow-lg overflow-hidden">
<div className="flex-shrink-0 px-4 pt-4 pb-2 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon />
</IconButton>
</div>
{showInfo && (
<InfoPopup
title="Points of Interest"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('osm-pois');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
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.
</p>
</InfoPopup>
)}
{showInfo && (
<InfoPopup
title="Points of Interest"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('osm-pois');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
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.
</p>
</InfoPopup>
)}
<div className="space-y-2" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 dark:border-navy-700 rounded hover:border-warm-400 bg-white dark:bg-navy-800 dark:text-warm-200"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === allCategories.length
? 'All categories'
: `${selectedCount} selected`}
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={selectAll}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
All
</button>
<button
onClick={selectNone}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
None
</button>
</div>
<span className="text-xs text-warm-500 dark:text-warm-400">
{selectedCount}/{allCategories.length} selected
</span>
<ChevronIcon
direction={dropdownOpen ? 'up' : 'down'}
className="w-4 h-4 ml-2 flex-shrink-0"
/>
</button>
</div>
{dropdownOpen && (
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search categories..."
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{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 (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
<button
onClick={() => toggleCollapse(group.name)}
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
{selectedCount > 0 && (
<div className="px-3 py-2 bg-teal-50 dark:bg-teal-900/30 rounded text-sm flex items-center justify-between">
<span className="font-medium text-teal-900 dark:text-teal-300">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</span>
</div>
)}
</div>
{selectedCount > 0 && (
<div className="p-3 bg-teal-50 dark:bg-teal-900/30 rounded text-sm">
<div className="font-medium text-teal-900 dark:text-teal-300">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</div>
<div className="text-xs text-teal-700 dark:text-teal-400 mt-1">
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
</div>
</div>
)}
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
{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;
<div className="p-3 bg-warm-100 dark:bg-navy-800 rounded text-xs text-warm-600 dark:text-warm-400">
<p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
<button
onClick={() => toggleCollapse(group.name)}
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded accent-teal-600"
/>
<span className="text-xs font-semibold text-warm-700 dark:text-warm-300">
{group.name}
</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 dark:hover:bg-navy-700 cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div>
);

View file

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

View file

@ -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<HTMLDivElement>(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<number, { sum: number; count: number }>();
// 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<number, number[]>();
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 (
<svg
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
preserveAspectRatio="none"
className="w-full"
style={{ height: HEIGHT }}
>
{/* Grid lines */}
{priceTicks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
y1={scaleY(tick)}
x2={VB_W - PADDING.right}
y2={scaleY(tick)}
className="stroke-warm-200 dark:stroke-warm-700"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
<div ref={containerRef} style={{ height: HEIGHT }}>
{width > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceTicks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
y1={scaleY(tick)}
x2={width - PADDING.right}
y2={scaleY(tick)}
className="stroke-warm-200 dark:stroke-warm-700"
strokeWidth={1}
/>
))}
{/* Dots */}
{points.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}
cy={scaleY(p.price)}
r={4}
className="fill-teal-500 dark:fill-teal-400"
opacity={0.35}
/>
))}
{/* Dots (clamp outliers to visible range) */}
{points.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}
cy={scaleY(p.price)}
r={3}
className="fill-teal-500 dark:fill-teal-400"
opacity={0.35}
/>
))}
{/* Average line */}
{averages.length > 1 && (
<polyline
points={avgPolyline}
fill="none"
className="stroke-teal-600 dark:stroke-teal-400"
strokeWidth={3}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
{/* Median line */}
{medians.length > 1 && (
<polyline
points={medianPolyline}
fill="none"
className="stroke-teal-600 dark:stroke-teal-400"
strokeWidth={2}
strokeLinejoin="round"
/>
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{yr}
</text>
))}
</svg>
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{formatValue(tick)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{yr}
</text>
))}
</svg>
</div>
);
}
@ -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;

View file

@ -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<SortBy>('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({
</InfoPopup>
)}
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by address or postcode..."
className="p-2"
/>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200"
>
<option value="price">Price (High to Low)</option>
<option value="size">Size (Large to Small)</option>
<option value="energy">Energy Rating (Best to Worst)</option>
</select>
</div>
<div className="flex-1 overflow-y-auto">
@ -114,7 +92,7 @@ export function PropertiesPane({
<div className="p-4 dark:text-warm-400">Loading...</div>
) : (
<>
{filteredAndSorted.map((property, idx) => (
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (

View file

@ -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<string, string>;
}
/** 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
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor: colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">

View file

@ -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 (
<div className="text-xs text-warm-400 dark:text-warm-500 italic mt-1">All low</div>
);
}
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {
const total = Object.values(stats.counts).reduce((a, b) => a + b, 0);
return (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-24 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
{shortenLabel(label)}
</span>
<div className="flex-1 flex h-3.5 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{valueOrder.map((value, i) => {
const count = stats.counts[value] ?? 0;
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={value}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
title={`${value}: ${count} (${pct.toFixed(0)}%)`}
/>
);
})}
</div>
</div>
);
})}
{/* Legend */}
<div className="flex gap-x-3 gap-y-0.5 justify-center">
{valueOrder.map((value, i) => (
<div key={value} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{ backgroundColor: valueColors[i] }}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">{value}</span>
</div>
))}
</div>
</div>
);
}

View file

@ -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<void>;
onRegister: (email: string, password: string, name?: string) => Promise<void>;
loading: boolean;
error: string | null;
onClearError: () => void;
}) {
const [tab, setTab] = useState<Tab>('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 (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
{tab === 'login' ? 'Log in' : 'Create account'}
</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex px-5 gap-4 border-b border-warm-200 dark:border-warm-700">
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'login'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('login')}
>
Log in
</button>
<button
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
tab === 'register'
? 'border-teal-600 text-teal-600 dark:text-teal-400 dark:border-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`}
onClick={() => switchTab('register')}
>
Register
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{tab === '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-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400"
placeholder="Your name (optional)"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => 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'}
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{loading ? 'Please wait...' : tab === 'login' ? 'Log in' : 'Create account'}
</button>
</form>
</div>
</div>
);
}

View file

@ -17,8 +17,8 @@ export function FeatureLabel({
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
return (
<div className={`flex items-center gap-1 min-w-0 ${className}`}>
<span className={`${textClass} text-warm-700 dark:text-warm-300 truncate`}>
<div className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}>
<span className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}>
{feature.name}
</span>
{feature.detail && onShowInfo && (

View file

@ -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')}
>
<svg
className="w-5 h-5 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<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">
@ -70,73 +70,52 @@ export default function Header({
</nav>
</div>
<div className="flex items-center gap-2">
{activePage === 'dashboard' && (
<>
<button
onClick={onExport ?? undefined}
disabled={!onExport || exporting}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
title="Export to Excel"
>
<DownloadIcon className="w-4 h-4" />
{exporting ? 'Exporting...' : 'Export'}
</button>
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied!
</>
) : (
<>
<ClipboardIcon className="w-4 h-4" />
Share
</>
)}
</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={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' ? (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
</button>
{activePage === 'dashboard' && (
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
Share
</>
)}
</button>
)}
</div>
</header>
);

View file

@ -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<HTMLDivElement>(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 (
<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}
>
{initial}
</button>
{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>
</div>
<div className="p-1">
<button
onClick={() => {
setOpen(false);
onLogout();
}}
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Log out
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function CheckIcon({ 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="M5 13l4 4L19 7" />
</svg>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function ClipboardIcon({ 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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
);
}

View file

@ -0,0 +1,12 @@
interface IconProps {
className?: string;
}
export function DownloadIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m0 0l-6-6m6 6l6-6" />
<path strokeLinecap="round" strokeLinejoin="round" d="M5 21h14" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function MapPinIcon({ 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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function MoonIcon({ 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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
}

View file

@ -0,0 +1,12 @@
interface IconProps {
className?: string;
}
export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}

View file

@ -0,0 +1,15 @@
interface IconProps {
className?: string;
}
export function SunIcon({ 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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}

View file

@ -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<AuthUser | null>(() => {
if (pb.authStore.isValid && pb.authStore.record) {
return recordToUser(pb.authStore.record);
}
return null;
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}

View file

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

View file

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

View file

@ -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<string
categories: categoriesStr,
bounds: boundsStr,
});
const res = await fetch(apiUrl('pois', params), {
signal: abortControllerRef.current.signal,
});
const res = await fetch(
apiUrl('pois', params),
authHeaders({
signal: abortControllerRef.current.signal,
})
);
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {

View file

@ -8,6 +8,7 @@ body,
height: 100%;
margin: 0;
padding: 0;
overscroll-behavior: none;
}
html.dark {

View file

@ -1,25 +1,29 @@
import type { FeatureMeta, FeatureFilters } from '../types';
const INITIAL_RETRY_MS = 1000;
const MAX_RETRY_MS = 10000;
// Error handling utilities
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
export function logNonAbortError(label: string, error: unknown): void {
if (!isAbortError(error)) {
console.error(`${label}:`, error);
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error(`${label}:`, error);
}
export function authHeaders(init?: RequestInit): RequestInit {
const headers: Record<string, string> = {};
if (pb.authStore.isValid && pb.authStore.token) {
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
}
if (!init) return { headers };
const existing = init.headers as Record<string, string> | 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<T>(
@ -30,7 +34,7 @@ export async function fetchWithRetry<T>(
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<T>(
}
}
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 '';

View file

@ -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 = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
// =============================================================================
// 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<string, {
/** Display label for the chart */
label: string;
/** If set, use this feature's stats for the total and info popup. Otherwise sum components. */
@ -78,13 +80,7 @@ export interface StackedChartConfig {
unit?: string;
/** Feature names that make up the segments */
components: string[];
}
/**
* 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<string, StackedChartConfig[]> = {
}[]> = {
Crime: [
{
label: 'Serious crime',
@ -124,6 +120,56 @@ export const STACKED_GROUPS: Record<string, StackedChartConfig[]> = {
],
};
/**
* 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<string, {
/** Display label for the chart */
label: string;
/** If set, use this feature for the info popup */
feature?: string;
/** Enum feature names that make up the rows */
components: string[];
/** Value order for consistent segment ordering */
valueOrder: string[];
/** Colors for each value (matches valueOrder) */
valueColors: string[];
}[]> = {
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

View file

@ -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<string, ValueFormat> = {
// 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;
}

View file

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

View file

@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('/pb');
export default pb;

View file

@ -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})`;
}

View file

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