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