This commit is contained in:
Andras Schmelczer 2026-05-09 09:26:40 +01:00
parent 701c17a703
commit f114ada255
44 changed files with 5264 additions and 1674 deletions

View file

@ -8,7 +8,7 @@ import type { AiFilterErrorType } from '../../hooks/useAiFilters';
/** Cycle through loading messages to show progress. */
function useLoadingMessage(loading: boolean, messages: string[]): string {
const [index, setIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!loading) {
@ -20,7 +20,7 @@ function useLoadingMessage(loading: boolean, messages: string[]): string {
const t2 = setTimeout(() => setIndex(2), 3500);
const t3 = setTimeout(() => setIndex(3), 5500);
return () => {
clearTimeout(timerRef.current);
if (timerRef.current) clearTimeout(timerRef.current);
clearTimeout(t2);
clearTimeout(t3);
};

View file

@ -16,7 +16,12 @@ import {
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { PARTY_FEATURE_COLORS, STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts';
import {
PARTY_FEATURE_COLORS,
STACKED_GROUPS,
STACKED_ENUM_GROUPS,
STACKED_SEGMENT_COLORS,
} from '../../lib/consts';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
@ -113,71 +118,75 @@ export default function AreaPane({
return (
<>
<div className="h-full overflow-y-auto">
<div className="p-3">
<div className="flex items-center gap-2">
<div>
<h2 className="text-sm font-semibold dark:text-warm-100">
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
</h2>
{isPostcode && (
<span className="text-xs text-warm-500 dark:text-warm-400">
{t('common.postcode')}
</span>
)}
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
</h2>
{loading && (
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
)}
</div>
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
</div>
</div>
</div>
{loading && stats && (
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
<div className="flex gap-2 border-l-2 border-teal-500 bg-warm-50 px-2.5 py-2 text-xs leading-snug text-warm-700 dark:bg-navy-900 dark:text-warm-300">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-700 dark:text-teal-300" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
</div>
)}
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
{t('areaPane.viewPropertiesShort')}
</button>
)}
</div>
{propertyCount != null && (
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
{propertyCount.toLocaleString()} {t('common.propertiesPlural')}
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
</p>
<div className="mt-2 flex gap-2 rounded border border-teal-200 bg-teal-50 px-2.5 py-2 text-xs leading-snug text-teal-800 dark:border-teal-800/70 dark:bg-teal-950/40 dark:text-teal-200">
<FilterIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
{activeFilterCount > 0
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{hasFilteredOutArea && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.noFilteredMatches')}</p>
<p className="mt-1">
{unfilteredCount != null && unfilteredCount > 0
? t('areaPane.unfilteredAreaCount', { count: unfilteredCount })
: unfilteredCount === 0
? t('areaPane.noUnfilteredAreaProperties')
: t('areaPane.relaxFiltersHint')}
</p>
{onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('filters.clearAll')}
</button>
)}
</div>
)}
{stats && stats.count > 0 && (
<button
onClick={onViewProperties}
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
{t('areaPane.viewProperties', { count: stats.count })}
</button>
)}
</div>
{hexagonLocation && stats && (
@ -315,7 +324,7 @@ export default function AreaPane({
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: undefined
: STACKED_SEGMENT_COLORS
}
/>
</div>
@ -369,7 +378,15 @@ export default function AreaPane({
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
meanLabel={t('areaPane.nationalAvg')}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
) : (
<DualHistogram
@ -377,7 +394,14 @@ export default function AreaPane({
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) => formatFilterValue(v, feature.raw)}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
))}
</div>

View file

@ -34,6 +34,7 @@ export function DualHistogram({
p1,
p99,
globalMean,
meanLabel = 'National avg',
formatLabel,
}: {
localCounts: number[];
@ -41,6 +42,7 @@ export function DualHistogram({
p1: number;
p99: number;
globalMean?: number;
meanLabel?: string;
formatLabel?: (value: number) => string;
}) {
const targetBars = 25;
@ -84,34 +86,51 @@ export function DualHistogram({
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;
const showMeanMarker = meanPct != null && meanPct >= 0 && meanPct <= 100;
const meanLabelStyle =
showMeanMarker && meanPct < 12
? { left: 0 }
: showMeanMarker && meanPct > 88
? { right: 0 }
: { left: '50%', transform: 'translateX(-50%)' };
return (
<div className="mt-1">
<div className="relative flex items-end gap-px h-10">
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
<div
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
style={{ height: `${globalHeight}%` }}
/>
<div
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
style={{
height: `${localHeight}%`,
opacity: localBars[index] > 0 ? 1 : 0.1,
}}
/>
</div>
);
})}
{meanPct != null && meanPct >= 0 && meanPct <= 100 && (
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
<div className="relative flex items-end gap-px h-10">
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
<div
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
style={{ height: `${globalHeight}%` }}
/>
<div
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
style={{
height: `${localHeight}%`,
opacity: localBars[index] > 0 ? 1 : 0.1,
}}
/>
</div>
);
})}
</div>
{showMeanMarker && (
<div
className="absolute bottom-0 top-0 w-px border-l border-dashed border-warm-400 dark:border-warm-500"
className="pointer-events-none absolute inset-y-0"
style={{ left: `${meanPct}%` }}
/>
>
<div
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
style={meanLabelStyle}
>
{meanLabel}
</div>
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
</div>
)}
</div>
{tickBars.size > 0 && (

View file

@ -0,0 +1,157 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MobileBottomSheet from './MobileBottomSheet';
class FakeVisualViewport extends EventTarget {
height: number;
width = 390;
offsetTop = 0;
offsetLeft = 0;
pageTop = 0;
pageLeft = 0;
scale = 1;
onresize: ((this: VisualViewport, ev: Event) => unknown) | null = null;
onscroll: ((this: VisualViewport, ev: Event) => unknown) | null = null;
constructor(height: number) {
super();
this.height = height;
}
}
const originalInnerHeight = window.innerHeight;
const originalVisualViewport = window.visualViewport;
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
const originalSetPointerCapture = HTMLElement.prototype.setPointerCapture;
function installViewport({
innerHeight,
visualHeight,
}: {
innerHeight: number;
visualHeight: number;
}) {
const visualViewport = new FakeVisualViewport(visualHeight);
Object.defineProperty(window, 'innerHeight', {
configurable: true,
writable: true,
value: innerHeight,
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: visualViewport as unknown as VisualViewport,
});
return visualViewport;
}
function renderSheet() {
const coveredHeights: number[] = [];
const view = render(
<MobileBottomSheet onCoveredHeightChange={(height) => coveredHeights.push(height)}>
<label>
Name
<input aria-label="Name" />
</label>
<button type="button">Apply</button>
</MobileBottomSheet>
);
const sheet = view.container.querySelector('section');
if (!(sheet instanceof HTMLElement)) throw new Error('Expected bottom sheet section');
return { ...view, coveredHeights, sheet };
}
describe('MobileBottomSheet keyboard avoidance', () => {
beforeEach(() => {
HTMLElement.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.setPointerCapture = vi.fn();
});
afterEach(() => {
cleanup();
Object.defineProperty(window, 'innerHeight', {
configurable: true,
writable: true,
value: originalInnerHeight,
});
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: originalVisualViewport,
});
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: originalScrollIntoView,
});
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: originalSetPointerCapture,
});
});
it('ignores visual viewport keyboard inset until a sheet text field is focused', () => {
const visualViewport = installViewport({ innerHeight: 800, visualHeight: 500 });
const { sheet } = renderSheet();
expect(sheet.style.bottom).toBe('0px');
act(() => {
visualViewport.dispatchEvent(new Event('resize'));
});
expect(sheet.style.bottom).toBe('0px');
});
it('clears keyboard offset when focus leaves even if visual viewport is stale', async () => {
installViewport({ innerHeight: 800, visualHeight: 500 });
const { sheet } = renderSheet();
const input = screen.getByLabelText('Name');
await act(async () => {
input.focus();
});
expect(sheet.style.bottom).toBe('300px');
await act(async () => {
input.blur();
});
expect(sheet.style.bottom).toBe('0px');
});
it('leaves keyboard avoidance mode when tapping non-editable sheet content', async () => {
installViewport({ innerHeight: 800, visualHeight: 500 });
const { sheet } = renderSheet();
const input = screen.getByLabelText('Name');
await act(async () => {
input.focus();
});
expect(sheet.style.bottom).toBe('300px');
await act(async () => {
fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply' }));
});
expect(document.activeElement).not.toBe(input);
expect(sheet.style.bottom).toBe('0px');
});
it('reports covered height while the drawer is being dragged', async () => {
installViewport({ innerHeight: 800, visualHeight: 800 });
const { coveredHeights, sheet } = renderSheet();
const handle = sheet.firstElementChild;
if (!(handle instanceof HTMLElement)) throw new Error('Expected bottom sheet drag handle');
expect(coveredHeights[coveredHeights.length - 1]).toBe(352);
await act(async () => {
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 });
});
expect(coveredHeights[coveredHeights.length - 1]).toBe(452);
});
});

View file

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { SEGMENT_COLORS } from '../../lib/consts';
import { formatValue, roundedPercentages } from '../../lib/format';
interface Segment {
@ -10,8 +9,7 @@ interface Segment {
interface StackedBarChartProps {
segments: Segment[];
total: number;
/** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */
colorMap?: Record<string, string>;
colorMap: Record<string, string>;
}
/** Strip common suffixes/prefixes to produce short legend labels */
@ -44,6 +42,14 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
return <div className="text-xs text-warm-400 dark:text-warm-500 italic">No data</div>;
}
const colorFor = (segmentName: string): string => {
const color = colorMap[segmentName];
if (!color) {
throw new Error(`Missing stacked bar color for '${segmentName}'`);
}
return color;
};
return (
<div className="space-y-1.5">
{/* Stacked bar */}
@ -57,8 +63,7 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
className="h-full"
style={{
width: `${pct}%`,
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor: colorFor(segment.name),
}}
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`}
/>
@ -68,13 +73,12 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
{sortedSegments.map((segment, i) => (
{sortedSegments.map((segment) => (
<div key={segment.name} className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-sm shrink-0"
style={{
backgroundColor:
colorMap?.[segment.name] ?? SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor: colorFor(segment.name),
}}
/>
<span className="text-[10px] text-warm-600 dark:text-warm-400">

View file

@ -86,14 +86,14 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
<InfoIcon className="w-3.5 h-3.5" />
</IconButton>
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned || isActive}
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
</IconButton>