More FE changes

This commit is contained in:
Andras Schmelczer 2026-05-09 09:43:41 +01:00
parent f114ada255
commit a48eb945e0
48 changed files with 4127 additions and 1751 deletions

View file

@ -56,6 +56,15 @@ function distToRatios(dist: unknown): number[] {
return r;
}
function requireEnumPalette(
palette: [number, number, number][] | null
): [number, number, number][] {
if (!palette) {
throw new Error('Enum layer requested without an enum color palette');
}
return palette;
}
export function useDeckLayers({
data,
postcodeData,
@ -127,9 +136,12 @@ export function useDeckLayers({
? colorFeatureMeta.values.length
: 0;
// Per-feature color palette (uses overrides when defined)
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
const enumPalette =
viewFeature && colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values
? getEnumPaletteForFeature(viewFeature, colorFeatureMeta.values)
: null;
const enumPaletteRef = useRef(enumPalette);
enumPaletteRef.current = enumPalette;
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1, total: 0 };
@ -256,27 +268,27 @@ export function useDeckLayers({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(enumPaletteRef.current)],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[8], r[9]];
},
updateTriggers: {
getCenter: [colorTrigger, data],
getRatios0: [colorTrigger, data],
getRatios1: [colorTrigger, data],
getRatios2: [colorTrigger, data],
},
}
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
return [r[8], r[9]];
},
updateTriggers: {
getCenter: [colorTrigger, data],
getRatios0: [colorTrigger, data],
getRatios1: [colorTrigger, data],
getRatios2: [colorTrigger, data],
},
}
: {};
return new H3HexagonLayer<HexagonData>({
@ -568,11 +580,15 @@ export function useDeckLayers({
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? zoom >= 16
? [postcodeLayer, postcodeLabelsLayer, ...poiLayers]
: [postcodeLayer, ...poiLayers]
: [hexLayer, ...poiLayers];
const baseLayers: any[] = [];
if (usePostcodeView) {
baseLayers.push(postcodeLayer);
if (zoom >= 16) baseLayers.push(postcodeLabelsLayer);
baseLayers.push(...poiLayers);
} else {
baseLayers.push(hexLayer);
baseLayers.push(...poiLayers);
}
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
return baseLayers;

View file

@ -17,6 +17,23 @@ import {
getSpecificCrimeFilterKeyId,
normalizeSpecificCrimeFilters,
} from '../lib/crime-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
getDefaultEthnicityFeatureName,
getEthnicityFeatureName,
getEthnicityFilterKeyId,
normalizeEthnicityFilters,
} from '../lib/ethnicity-filter';
import {
POI_FILTER_NAMES,
createPoiFilterKey,
getDefaultPoiFilterFeatureName,
getPoiDistanceFeatureName,
getPoiDistanceFilterKeyId,
normalizePoiDistanceFilters,
type PoiFilterName,
} from '../lib/poi-distance-filter';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -24,11 +41,19 @@ interface UseFiltersOptions {
}
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters));
return normalizePoiDistanceFilters(
normalizeEthnicityFilters(normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)))
);
}
function getBackendFeatureName(name: string): string {
return getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name;
return (
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name
);
}
function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters {
@ -85,6 +110,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const specificCrimeFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
);
const ethnicityFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
);
const poiDistanceFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -117,7 +148,15 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (name !== SCHOOL_FILTER_NAME && name !== SPECIFIC_CRIMES_FILTER_NAME && !meta) return;
if (
name !== SCHOOL_FILTER_NAME &&
name !== SPECIFIC_CRIMES_FILTER_NAME &&
name !== ETHNICITIES_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta
) {
return;
}
trackEvent('Filter Add', { feature: name });
setFilters((prev) => {
undoStackRef.current.push(prev);
@ -159,6 +198,42 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
],
};
}
if (name === ETHNICITIES_FILTER_NAME) {
const defaultEthnicityFeatureName = getDefaultEthnicityFeatureName(features);
const defaultEthnicityFeature = defaultEthnicityFeatureName
? features.find((feature) => feature.name === defaultEthnicityFeatureName)
: undefined;
if (!defaultEthnicityFeatureName) return prev;
return {
...prev,
[createEthnicityFilterKey(defaultEthnicityFeatureName, ethnicityFilterIdRef.current++)]:
[
defaultEthnicityFeature?.histogram?.min ?? defaultEthnicityFeature?.min ?? 0,
defaultEthnicityFeature?.histogram?.max ?? defaultEthnicityFeature?.max ?? 100,
],
};
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const poiFilterName = name as PoiFilterName;
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
const defaultPoiFeature = defaultPoiFeatureName
? features.find((feature) => feature.name === defaultPoiFeatureName)
: undefined;
if (!defaultPoiFeatureName) return prev;
return {
...prev,
[createPoiFilterKey(
poiFilterName,
defaultPoiFeatureName,
poiDistanceFilterIdRef.current++
)]: [
defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0,
defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5,
],
};
}
if (!meta) return prev;
if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] };
@ -234,6 +309,40 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (replaced) return normalizeFilters(next);
}
const ethnicityKeyId = getEthnicityFilterKeyId(name);
if (ethnicityKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getEthnicityFilterKeyId(existingName) === ethnicityKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const poiDistanceKeyId = getPoiDistanceFilterKeyId(name);
if (poiDistanceKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getPoiDistanceFilterKeyId(existingName) === poiDistanceKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
return normalizeFilters({ ...prev, [name]: value });
});
}, []);

View file

@ -18,6 +18,8 @@ import {
} from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime';
@ -86,7 +88,11 @@ export function useMapData({
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const getBackendFeatureName = useCallback(
(name: string) =>
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name,
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,
[]
);
const dataViewFeature = useMemo(
@ -279,9 +285,11 @@ export function useMapData({
useEffect(() => {
if (!bounds) {
latestDataRequestKeyRef.current = '';
setLoading(false);
return;
}
latestDataRequestKeyRef.current = dataRequestKey;
setLoading(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
@ -294,7 +302,6 @@ export function useMapData({
abortControllerRef.current = new AbortController();
const requestKey = dataRequestKey;
setLoading(true);
try {
if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsParam });

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Step, CallBackProps } from 'react-joyride';
import type { EventData, Step } from 'react-joyride';
const STORAGE_KEY = 'tutorial_completed';
const JOYRIDE_ACTION_CLOSE = 'close';
@ -18,43 +18,35 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
title: t('tutorial.step1Title'),
content: t('tutorial.step1Content'),
placement: 'right' as const,
disableBeacon: true,
skipBeacon: true,
},
{
target: '[data-tutorial="ai-filters"]',
title: t('tutorial.step2Title'),
content: t('tutorial.step2Content'),
placement: 'right' as const,
disableBeacon: true,
skipBeacon: true,
},
{
target: '[data-tutorial="map"]',
target: '[data-tutorial="map-anchor"]',
title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'),
placement: 'bottom' as const,
disableBeacon: true,
placement: 'top' as const,
skipBeacon: true,
},
{
target: '[data-tutorial="search"]',
title: t('tutorial.step4Title'),
content: t('tutorial.step4Content'),
placement: 'bottom' as const,
disableBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
title: t('tutorial.step5Title'),
content: t('tutorial.step5Content'),
placement: 'left' as const,
disableBeacon: true,
skipBeacon: true,
},
{
target: '[data-tutorial="poi-button"]',
title: t('tutorial.step6Title'),
content: t('tutorial.step6Content'),
placement: 'left' as const,
disableBeacon: true,
styles: { tooltip: { transform: 'translateY(-50px)' } },
skipBeacon: true,
},
],
[t]
@ -67,7 +59,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
const shouldRun = run && !initialLoading && !isMobile && !blocked;
const handleCallback = useCallback((data: CallBackProps) => {
const handleCallback = useCallback((data: EventData) => {
const { status, action, type } = data;
if (status === JOYRIDE_STATUS_FINISHED || status === JOYRIDE_STATUS_SKIPPED) {