Improve UI

This commit is contained in:
Andras Schmelczer 2026-02-05 21:19:19 +00:00
parent 5fe192d25a
commit ae29662c92
14 changed files with 221 additions and 313 deletions

View file

@ -3,8 +3,10 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData }
import type { HexagonLocation } from '../lib/external-search';
import { formatValue, calculateHistogramMean } from '../lib/format';
import { groupFeaturesByCategory } from '../lib/features';
import { CRIME_BREAKDOWNS } from '../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, CloseIcon } from './ui/Icons';
import { IconButton } from './ui/IconButton';
@ -108,17 +110,67 @@ export default function AreaPane({
);
if (!hasData) return null;
// For Crime group, only show aggregate features with stacked breakdown
const isCrimeGroup = group.name === 'Crime';
const featuresToRender = isCrimeGroup
? group.features.filter((f) => f.name in CRIME_BREAKDOWNS)
: group.features;
return (
<div key={group.name}>
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
{group.name}
</h3>
<div className="space-y-3">
{group.features.map((feature) => {
{featuresToRender.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
// Check if this is a crime aggregate that should show breakdown
const breakdown = CRIME_BREAKDOWNS[feature.name];
if (breakdown) {
// Build segments from component crime means
const segments = breakdown
.map((componentName) => {
const componentStats = numericByName.get(componentName);
return {
name: componentName,
value: componentStats?.mean ?? 0,
};
})
.filter((s) => s.value > 0);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
<div className="flex items-center gap-1 min-w-0 mr-2">
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
{feature.name}
</span>
{feature.detail && (
<button
onClick={() => setInfoFeature(feature)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Feature info"
>
<InfoIcon className="w-3 h-3" />
</button>
)}
</div>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean)} avg/yr
</span>
</div>
<StackedBarChart segments={segments} total={numericStats.mean} />
</div>
);
}
// Regular numeric feature with histogram
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram

View file

@ -1,10 +1,9 @@
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import { Slider } from './ui/Slider';
import { Label } from './ui/Label';
import { SearchInput } from './ui/SearchInput';
import { SelectionButtons } from './ui/SelectionButtons';
import { ChevronIcon, FilterIcon, LightbulbIcon } from './ui/Icons';
import { IconButton } from './ui/IconButton';
import { FilterIcon, LightbulbIcon } from './ui/Icons';
import { EmptyState } from './ui/EmptyState';
import type { FeatureMeta, FeatureFilters } from '../types';
import { formatFilterValue } from '../lib/format';
@ -34,7 +33,6 @@ interface FiltersProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
onCollapse?: () => void;
}
function FeatureBrowser({
@ -152,7 +150,6 @@ export default memo(function Filters({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
onCollapse,
}: FiltersProps) {
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
@ -187,11 +184,6 @@ export default memo(function Filters({
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">
{onCollapse && (
<IconButton onClick={onCollapse} title="Collapse filters">
<ChevronIcon direction="left" />
</IconButton>
)}
<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"

View file

@ -3,7 +3,7 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
import { IconLayer, PolygonLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import type {
@ -11,7 +11,6 @@ import type {
PostcodeData,
ViewState,
ViewChangeParams,
Bounds,
POI,
FeatureMeta,
} from '../types';
@ -23,7 +22,6 @@ import {
getBoundsFromViewState,
emojiToTwemojiUrl,
getMapStyle,
POSTCODE_ZOOM_THRESHOLD,
} from '../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts';
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
@ -39,6 +37,18 @@ function osmIdToUrl(id: string): string | null {
return `https://www.openstreetmap.org/${typeMap[match[1]]}/${match[2]}`;
}
/** Calculate the centroid of a polygon from its vertices */
function polygonCentroid(vertices: [number, number][]): [number, number] {
if (vertices.length === 0) return [0, 0];
let sumLng = 0;
let sumLat = 0;
for (const [lng, lat] of vertices) {
sumLng += lng;
sumLat += lat;
}
return [sumLng / vertices.length, sumLat / vertices.length];
}
interface MapProps {
data: HexagonData[];
postcodeData: PostcodeData[];
@ -437,6 +447,30 @@ export default memo(function Map({
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
);
const postcodeLabelsLayer = useMemo(
() =>
new TextLayer<PostcodeData>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (d) => polygonCentroid(d.vertices),
getText: (d) => d.postcode,
getSize: 12,
getColor: theme === 'dark' ? [220, 220, 220, 220] : [40, 40, 40, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
billboard: false,
pickable: false,
}),
[postcodeData, theme]
);
const poiLayer = useMemo(
() =>
new IconLayer<POI>({
@ -488,12 +522,14 @@ export default memo(function Map({
}, [searchedPostcode, searchedPostcodeHasData]);
const layers = useMemo(() => {
const baseLayers = usePostcodeView ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer];
const baseLayers = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) {
return [...baseLayers, searchedPostcodeHighlightLayer];
}
return baseLayers;
}, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]);
}, [usePostcodeView, hexLayer, postcodeLayer, postcodeLabelsLayer, poiLayer, searchedPostcodeHighlightLayer]);
return (
<div className="flex-1 h-full relative" ref={containerRef}>

View file

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { CRIME_SEGMENT_COLORS } from '../lib/consts';
import { formatValue } from '../lib/format';
interface Segment {
name: string;
value: number;
}
interface StackedBarChartProps {
segments: Segment[];
total: number;
}
/** Shorten crime category names for the legend */
function shortenCrimeName(name: string): string {
return name
.replace(' (avg/yr)', '')
.replace('and sexual offences', '')
.replace('and arson', '')
.replace('from the person', '')
.replace('Possession of weapons', 'Weapons')
.replace('Anti-social behaviour', 'Anti-social')
.replace('Criminal damage', 'Damage')
.trim();
}
export default function StackedBarChart({ segments, total }: StackedBarChartProps) {
const sortedSegments = useMemo(
() => [...segments].sort((a, b) => b.value - a.value),
[segments]
);
if (total === 0) {
return (
<div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>
);
}
return (
<div className="space-y-1.5">
{/* Stacked bar */}
<div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
{sortedSegments.map((segment, i) => {
const pct = (segment.value / total) * 100;
if (pct < 0.5) return null; // Skip tiny segments
return (
<div
key={segment.name}
className="h-full transition-all"
style={{
width: `${pct}%`,
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
}}
title={`${shortenCrimeName(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
/>
);
})}
</div>
{/* Legend - compact grid */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment, i) => (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">
{shortenCrimeName(segment.name)}
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
{formatValue(segment.value)}
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -1,89 +0,0 @@
import { useCallback } from 'react';
interface CheckboxListProps {
items: string[];
selected: string[] | Set<string>;
onChange: (selected: string[]) => void;
className?: string;
}
export function CheckboxList({ items, selected, onChange, className = '' }: CheckboxListProps) {
const selectedSet = selected instanceof Set ? selected : new Set(selected);
const handleToggle = useCallback(
(item: string) => {
const newSelected = selectedSet.has(item)
? [...selectedSet].filter((v) => v !== item)
: [...selectedSet, item];
onChange(newSelected);
},
[selectedSet, onChange]
);
return (
<div className={`space-y-0.5 ${className}`}>
{items.map((item) => (
<label
key={item}
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
>
<input
type="checkbox"
checked={selectedSet.has(item)}
onChange={() => handleToggle(item)}
className="rounded accent-teal-600"
/>
{item}
</label>
))}
</div>
);
}
interface CheckboxListWithSetProps {
items: string[];
selected: Set<string>;
onChange: (selected: Set<string>) => void;
className?: string;
itemClassName?: string;
}
export function CheckboxListWithSet({
items,
selected,
onChange,
className = '',
itemClassName = '',
}: CheckboxListWithSetProps) {
const handleToggle = useCallback(
(item: string) => {
const newSet = new Set(selected);
if (newSet.has(item)) {
newSet.delete(item);
} else {
newSet.add(item);
}
onChange(newSet);
},
[selected, onChange]
);
return (
<div className={className}>
{items.map((item) => (
<label
key={item}
className={`flex items-center gap-2 cursor-pointer dark:text-warm-300 ${itemClassName}`}
>
<input
type="checkbox"
checked={selected.has(item)}
onChange={() => handleToggle(item)}
className="rounded accent-teal-600"
/>
<span className="text-sm flex-1">{item}</span>
</label>
))}
</div>
);
}

View file

@ -1,5 +1,3 @@
// Shared icon components with consistent sizing and styling
interface IconProps {
className?: string;
}

View file

@ -1,81 +0,0 @@
import { useRef, useEffect, useCallback } from 'react';
import { isAbortError, logNonAbortError } from '../lib/api';
const DEFAULT_DEBOUNCE_MS = 150;
interface UseDebouncedFetchOptions {
debounceMs?: number;
}
interface UseDebouncedFetchResult {
fetch: (url: string, onSuccess: (data: unknown) => void) => void;
cancel: () => void;
}
export function useDebouncedFetch(
options: UseDebouncedFetchOptions = {}
): UseDebouncedFetchResult {
const { debounceMs = DEFAULT_DEBOUNCE_MS } = options;
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const cancel = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const fetchFn = useCallback(
(url: string, onSuccess: (data: unknown) => void) => {
// Clear any pending debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
// Abort any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const res = await fetch(url, { signal: abortControllerRef.current.signal });
const json = await res.json();
onSuccess(json);
} catch (err) {
logNonAbortError(`Failed to fetch ${url}`, err);
}
}, debounceMs);
},
[debounceMs]
);
// Cleanup on unmount
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return { fetch: fetchFn, cancel };
}
// Typed version with generic
export function useTypedDebouncedFetch<T>(
options: UseDebouncedFetchOptions = {}
): {
fetch: (url: string, onSuccess: (data: T) => void) => void;
cancel: () => void;
} {
const result = useDebouncedFetch(options);
return {
fetch: result.fetch as (url: string, onSuccess: (data: T) => void) => void,
cancel: result.cancel,
};
}

View file

@ -1,27 +0,0 @@
import { useState, useCallback } from 'react';
interface UseInfoPopupResult<T> {
item: T | null;
isOpen: boolean;
open: (item: T) => void;
close: () => void;
}
export function useInfoPopup<T>(): UseInfoPopupResult<T> {
const [item, setItem] = useState<T | null>(null);
const open = useCallback((newItem: T) => {
setItem(newItem);
}, []);
const close = useCallback(() => {
setItem(null);
}, []);
return {
item,
isOpen: item !== null,
open,
close,
};
}

View file

@ -1,55 +0,0 @@
import { useState, useMemo } from 'react';
interface UseSearchResult<T> {
query: string;
setQuery: (query: string) => void;
filtered: T[];
}
export function useSearch<T>(
items: T[],
getSearchableText: (item: T) => string
): UseSearchResult<T> {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!query.trim()) return items;
const lower = query.toLowerCase();
return items.filter((item) => getSearchableText(item).toLowerCase().includes(lower));
}, [items, query, getSearchableText]);
return { query, setQuery, filtered };
}
// Variant for searching groups with nested items
export function useGroupSearch<G extends { name: string }, T>(
groups: G[],
getItems: (group: G) => T[],
getSearchableText: (item: T) => string,
createGroup: (group: G, filteredItems: T[]) => G
): { query: string; setQuery: (query: string) => void; filtered: G[] } {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!query.trim()) return groups;
const lower = query.toLowerCase();
return groups
.map((group) => {
// If group name matches, return whole group
if (group.name.toLowerCase().includes(lower)) return group;
// Otherwise filter items
const items = getItems(group);
const matchingItems = items.filter((item) =>
getSearchableText(item).toLowerCase().includes(lower)
);
if (matchingItems.length === 0) return null;
return createGroup(group, matchingItems);
})
.filter((g): g is G => g !== null);
}, [groups, query, getItems, getSearchableText, createGroup]);
return { query, setQuery, filtered };
}

View file

@ -5,10 +5,10 @@ import type { ViewState } from '../types';
// =============================================================================
/** Geographic bounds constraining map panning [west, south, east, north] */
export const MAP_BOUNDS: [number, number, number, number] = [-12, 49, 4, 62];
export const MAP_BOUNDS: [number, number, number, number] = [-9.5, 49, 5, 57];
/** Minimum zoom level (can't zoom out further) */
export const MAP_MIN_ZOOM = 5;
export const MAP_MIN_ZOOM = 5.5;
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
export const TILE_MAX_ZOOM = 15;
@ -74,3 +74,43 @@ export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/
/** OpenStreetMap attribution HTML */
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';
// =============================================================================
// Crime Category Breakdowns
// =============================================================================
/** Component crimes that make up each aggregate crime metric */
export const CRIME_BREAKDOWNS: Record<string, string[]> = {
'Serious crime (avg/yr)': [
'Violence and sexual offences (avg/yr)',
'Robbery (avg/yr)',
'Burglary (avg/yr)',
'Possession of weapons (avg/yr)',
],
'Minor crime (avg/yr)': [
'Anti-social behaviour (avg/yr)',
'Criminal damage and arson (avg/yr)',
'Shoplifting (avg/yr)',
'Bicycle theft (avg/yr)',
'Theft from the person (avg/yr)',
'Other theft (avg/yr)',
'Vehicle crime (avg/yr)',
'Public order (avg/yr)',
'Drugs (avg/yr)',
'Other crime (avg/yr)',
],
};
/** Colors for crime breakdown segments (designed for 10 distinct categories) */
export const CRIME_SEGMENT_COLORS = [
'#ef4444', // red-500
'#f97316', // orange-500
'#eab308', // yellow-500
'#22c55e', // green-500
'#14b8a6', // teal-500
'#06b6d4', // cyan-500
'#3b82f6', // blue-500
'#8b5cf6', // violet-500
'#d946ef', // fuchsia-500
'#ec4899', // pink-500
];

View file

@ -22,15 +22,3 @@ export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[]
return groups;
}
// Feature lookup utilities
export function getFeatureByName(
name: string,
features: FeatureMeta[]
): FeatureMeta | undefined {
return features.find((f) => f.name === name);
}
export function createFeatureMap(features: FeatureMeta[]): Map<string, FeatureMeta> {
return new Map(features.map((f) => [f.name, f]));
}

View file

@ -1,33 +1,5 @@
import type { Property } from '../types';
// Field aliases: maps human-readable names to snake_case names
// The server may return either depending on source
const FIELD_ALIASES: Record<string, string[]> = {
price: ['Last known price', 'latest_price'],
pricePerSqm: ['Price per sqm', 'price_per_sqm'],
floorArea: ['Total floor area (sqm)', 'total_floor_area'],
rooms: ['Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms'],
constructionAge: ['Approximate construction age', 'construction_age_band'],
councilTax: ['Council tax (£/yr)'],
councilTaxD: ['Council tax Band D (£/yr)'],
};
export function getPropertyNumber(
property: Property,
field: keyof typeof FIELD_ALIASES
): number | undefined {
const keys = FIELD_ALIASES[field];
if (!keys) return undefined;
for (const key of keys) {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') {
return v;
}
}
return undefined;
}
// Generic getter for any field names (for dynamic lookups)
export function getNum(property: Property, ...keys: string[]): number | undefined {
for (const key of keys) {