perfect-postcode/frontend/src/components/map/MapLegend.tsx

254 lines
8 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server';
import {
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
getEnumPaletteForFeature,
getFeatureGradient,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
function ResetScaleIcon({ className = 'w-4 h-4' }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12a9 9 0 0115.13-6.36L21 8" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 3v5h-5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 01-15.13 6.36L3 16" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 21v-5h5" />
</svg>
);
}
function requireFeatureName(featureName: string | undefined): string {
if (!featureName) {
throw new Error('Enum legend requested without a feature name');
}
return featureName;
}
function requireEnumPalette(
palette: [number, number, number][] | null
): [number, number, number][] {
if (!palette) {
throw new Error('Enum legend requested without a palette');
}
return palette;
}
function EnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex flex-col gap-1">
{values.map((label, i) => {
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1.5">
<div
className="w-3 h-3 rounded-sm shrink-0"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
<span className="text-warm-600 dark:text-warm-300 truncate">{ts(label)}</span>
</div>
);
})}
</div>
);
}
function InlineEnumSwatches({
values,
palette,
}: {
values: string[];
palette: [number, number, number][];
}) {
return (
<div className="flex items-center gap-2 flex-1 min-w-[40%] flex-wrap">
{values.map((label, i) => {
const color = palette[i % palette.length];
return (
<div key={label} className="flex items-center gap-1">
<div
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
{ts(label)}
</span>
</div>
);
})}
</div>
);
}
export default function MapLegend({
featureLabel,
range,
showCancel,
onCancel,
mode,
enumValues,
featureName,
theme = 'light',
inline = false,
suffix,
raw,
totalCount,
onResetScale,
resetScaleDisabled = false,
}: {
featureLabel: string;
range: [number, number];
showCancel: boolean;
onCancel: () => void;
mode: 'feature' | 'density';
enumValues?: string[];
featureName?: string;
theme?: 'light' | 'dark';
inline?: boolean;
suffix?: string;
raw?: boolean;
totalCount?: number;
onResetScale?: () => void;
resetScaleDisabled?: boolean;
}) {
const { t } = useTranslation();
const isEnum = enumValues && enumValues.length > 0;
const showResetScale = Boolean(onResetScale) && !isEnum;
const enumPalette = isEnum
? getEnumPaletteForFeature(requireFeatureName(featureName), enumValues)
: null;
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle =
mode === 'density'
? gradientToCss(densityGradient)
: gradientToCss(getFeatureGradient(featureName));
const fmt = raw ? { raw: true } : undefined;
const rangeMin =
mode === 'density' ? (
<TickerValue text={formatValue(range[0])} />
) : isEnum ? null : (
<TickerValue text={formatValue(range[0], fmt) + (suffix || '')} />
);
const rangeMax =
mode === 'density' ? (
<TickerValue text={formatValue(range[1])} />
) : isEnum ? null : (
<TickerValue text={formatValue(range[1], fmt) + (suffix || '')} />
);
if (inline) {
return (
<div className="bg-warm-100 dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-3 py-1.5 text-xs border-b border-warm-200 dark:border-warm-700 flex items-center gap-2">
<span className="font-semibold text-xs text-warm-800 dark:text-warm-200 truncate">
{featureLabel}
</span>
{showResetScale && (
<button
onClick={onResetScale}
disabled={resetScaleDisabled}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-40 disabled:hover:text-warm-400 dark:disabled:hover:text-warm-400 shrink-0"
title={t('mapLegend.resetColourScale')}
aria-label={t('mapLegend.resetColourScale')}
>
<ResetScaleIcon className="w-3.5 h-3.5" />
</button>
)}
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title={t('mapLegend.clearColourView')}
aria-label={t('mapLegend.clearColourView')}
>
<CloseIcon className="w-3.5 h-3.5" />
</button>
)}
{isEnum ? (
<InlineEnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
<div
className="h-2.5 rounded flex-1 min-w-[40px]"
style={{ background: gradientStyle }}
/>
{rangeMax}
</div>
)}
</div>
);
}
return (
<div className="bg-white dark:bg-navy-800 dark:text-white rounded shadow-lg p-3 text-xs min-w-[300px] pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm dark:text-white min-w-0 truncate">
{featureLabel}
</span>
{(showResetScale || showCancel) && (
<div className="flex items-center gap-1.5 ml-2 shrink-0">
{showResetScale && (
<button
onClick={onResetScale}
disabled={resetScaleDisabled}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-40 disabled:hover:text-warm-400 dark:disabled:hover:text-warm-400"
title={t('mapLegend.resetColourScale')}
aria-label={t('mapLegend.resetColourScale')}
>
<ResetScaleIcon className="w-4 h-4" />
</button>
)}
{showCancel && (
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title={t('mapLegend.clearColourView')}
aria-label={t('mapLegend.clearColourView')}
>
<CloseIcon className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
{isEnum ? (
<EnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
) : (
<>
<div className="h-3 rounded" style={{ background: gradientStyle }} />
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{rangeMin}
{rangeMax}
</div>
</>
)}
{totalCount != null && (
<div className="mt-2 pt-2 border-t border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 flex items-center justify-between">
<span>{t('common.total')}</span>
<span className="font-semibold text-navy-950 dark:text-warm-100">
<TickerValue text={formatValue(totalCount)} />
</span>
</div>
)}
</div>
);
}