254 lines
8 KiB
TypeScript
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>
|
|
);
|
|
}
|