Improve UI
This commit is contained in:
parent
5fe192d25a
commit
ae29662c92
14 changed files with 221 additions and 313 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
82
frontend/src/components/StackedBarChart.tsx
Normal file
82
frontend/src/components/StackedBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// Shared icon components with consistent sizing and styling
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue