LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
157
frontend/src/components/map/MobileBottomSheet.test.tsx
Normal file
157
frontend/src/components/map/MobileBottomSheet.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue