diff --git a/app/components/widgets/BarChartWidget.tsx b/app/components/widgets/BarChartWidget.tsx index 3687145..440173f 100644 --- a/app/components/widgets/BarChartWidget.tsx +++ b/app/components/widgets/BarChartWidget.tsx @@ -7,14 +7,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/app/components/ui/ta import CountyRank from './CountyRank'; import MetricsCards from './MetricsCards'; import { formatMetricValue } from '@/lib/utils/metricFormatters'; +import { selectBarChartData, selectColorScaleValues } from '@/lib/features/map/selectors'; export default function BarChartWidget() { const selectedMetric = useSelector((state: RootState) => state.filters.selectedMetric); - const temp = useSelector((state: RootState) => state.map.barChartData); - const validBarChartData = [...temp] - .filter((d): d is { county: string; value: number } => d && typeof d.value === 'number' && isFinite(d.value)) + const rawBarChartData = useSelector(selectBarChartData); + const validBarChartData = rawBarChartData + .filter((d): d is { county: string; value: number } => d != null && typeof d.value === 'number' && isFinite(d.value)) .sort((a, b) => a.value - b.value); - const colorScaleValues = useSelector((state: RootState) => state.map.colorScaleValues); + const colorScaleValues = useSelector(selectColorScaleValues); const colorScale = d3 .scaleSequential() diff --git a/app/components/widgets/CountyRank.tsx b/app/components/widgets/CountyRank.tsx index 7f54bd8..7b73eab 100644 --- a/app/components/widgets/CountyRank.tsx +++ b/app/components/widgets/CountyRank.tsx @@ -3,15 +3,15 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; -import { MetricType } from '@/lib/features/filters/filterSlice'; import { setSelectedCounty } from '@/lib/features/map/mapSlice'; +import { selectRankedCounties } from '@/lib/features/map/selectors'; import { motion, AnimatePresence } from 'framer-motion'; import { COUNTY_POPULATION } from '@/lib/constants/countyPopulation'; import { formatMetricLabel } from '@/lib/utils/metricFormatters'; export default function CountyRank() { const dispatch = useDispatch(); - const counties = useSelector((state: RootState) => state.filters.rankedCounties); + const counties = useSelector(selectRankedCounties); const selectedMetric = useSelector((state: RootState) => state.filters.selectedMetric); const isPerCapita = useSelector((state: RootState) => state.filters.isPerCapita); const selectedCounty = useSelector((state: RootState) => state.map.selectedCounty); diff --git a/app/components/widgets/MapStory.tsx b/app/components/widgets/MapStory.tsx index 987e790..e0cd6a8 100644 --- a/app/components/widgets/MapStory.tsx +++ b/app/components/widgets/MapStory.tsx @@ -9,7 +9,6 @@ import { useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from '@/lib/store'; import 'mapbox-gl/dist/mapbox-gl.css'; import { - setRankedCounties, DataSourceMetrics, getPopulationByCountyAndYear, getMetricAvailability, @@ -17,7 +16,8 @@ import { import { FlyToInterpolator } from '@deck.gl/core'; import type { ViewStateChangeParameters } from '@deck.gl/core'; -import { setBarChartData, setColorScaleValues, setSelectedCounty } from '@/lib/features/map/mapSlice'; +import { setEnhancedGeojson, setSelectedCounty } from '@/lib/features/map/mapSlice'; +import { selectEnhancedGeojson, selectColorScaleValues } from '@/lib/features/map/selectors'; import { Progress } from '@/app/components/ui/progress'; import { MapControls, MapLegend, MapTooltip } from './map'; import type { EnhancedFeature } from '@/app/types/map'; @@ -88,6 +88,8 @@ export default function MapStory() { activeFilters, } = useSelector((state: RootState) => state.filters); const selectedCounty = useSelector((state: RootState) => state.map.selectedCounty); + const enhancedGeojson = useSelector(selectEnhancedGeojson); + const colorScaleValues = useSelector(selectColorScaleValues); // Extract census data and selected year const censusData = csvDataSources.demographic; @@ -104,17 +106,13 @@ export default function MapStory() { } | null>(null); // State for managing the map's viewport (position, zoom, etc.). const [viewState, setViewState] = useState(INITIAL_VIEW_STATE); - // State to hold the numerical values used for the color scale (for legend/potential analysis). - const [colorValues, setColorValues] = useState([]); // State for expandable metric buttons const [isMetricsExpanded, setIsMetricsExpanded] = useState(false); - // --- NEW Worker Related State --- + // --- Worker Related State --- const workerRef = useRef(null); const [processing, setProcessing] = useState(false); // Loading state const [showLoading, setShowLoading] = useState(false); // Whether to show loading UI - // State to hold the results from the worker - const [enhancedGeojson, setEnhancedGeojson] = useState([]); const loadingTimerRef = useRef(null); // --- Effect to Initialize Worker --- @@ -130,7 +128,7 @@ export default function MapStory() { // Handle worker error (e.g., show message to user) } else { console.log('Message received from worker:', event.data.length, 'features'); - setEnhancedGeojson(event.data); // Update state with the computed features + dispatch(setEnhancedGeojson(event.data)); // Store worker output in Redux } setProcessing(false); // Calculation finished (or errored) setShowLoading(false); // Hide loading indicator @@ -226,94 +224,16 @@ export default function MapStory() { }, [geojsonData, filteredData, selectedMetric, selectedDataSource, isPerCapita, censusData, selectedYear]); // Dependencies that trigger recalculation /** - * Memoized calculation for the D3 color scale used to color the map features. - * It determines the scale's domain based on the current values in `enhancedGeojson` - * for the `selectedMetric`. It also updates the `colorValues` state. + * Memoized D3 color scale built from the selector-derived color scale values. */ - const colorScale = useMemo(() => { - const values = enhancedGeojson - .map((f) => { - // Access the property corresponding to the selectedMetric - const val = f.properties[selectedMetric]; - return val; - }) - // Filter out non-numeric or NaN values. - .filter((val): val is number => typeof val === 'number' && !isNaN(val)); - // Update the separate state holding just the numeric color values. - setColorValues(values); - // Create a sequential color scale using D3. - return ( + const colorScale = useMemo( + () => d3 .scaleSequential() - // Domain is from 0 to the maximum valid value found. - .domain([0, d3.max(values.filter((v) => isFinite(v))) || 0]) - // Use orange color interpolation. - .interpolator(d3.interpolateBlues) - ); - }, [enhancedGeojson, selectedMetric]); // Recalculate when features or metric change. - - /** - * Effect to dispatch the calculated color scale values to the Redux store. - * This allows other components (like a legend component) to access these values. - */ - useEffect(() => { - dispatch(setColorScaleValues(colorValues)); - }, [enhancedGeojson, selectedMetric, colorValues, dispatch]); // Dispatch when values change. - - /** - * Memoized calculation to rank counties based on the selected metric's value. - * Returns a sorted array of counties with their names, values, and ranks. - */ - const rankedCounties = useMemo(() => { - return ( - enhancedGeojson - .map((feature) => ({ - name: feature.properties.name, - value: feature.properties[selectedMetric], // Get the value for the current metric - rank: 0, // Placeholder for rank - })) - // Sort counties in descending order based on the metric value. - .sort((a, b) => b.value - a.value) - // Assign ranks based on the sorted order. - .map((county, index) => ({ - ...county, - rank: index + 1, - })) - ); - }, [enhancedGeojson, selectedMetric]); // Recalculate when features or metric change. - - /** - * Effect to dispatch the calculated ranked county list to the Redux store. - */ - useEffect(() => { - dispatch(setRankedCounties(rankedCounties)); - }, [rankedCounties, dispatch]); // Dispatch when ranks change. - - /** - * Memoized calculation for data formatted for a potential bar chart component. - * Sorts counties based on the current display value (raw or per capita). - */ - const barChartData = useMemo(() => { - const data = enhancedGeojson - .map((feature) => ({ - county: feature.properties.name, - // Use perCapitaValue if available and toggled, otherwise use rawValue. - value: - isPerCapita && feature.properties.perCapitaValue !== undefined - ? feature.properties.perCapitaValue - : feature.properties.rawValue, - })) - // Sort descending by value. - .sort((a, b) => b.value - a.value); - return data; - }, [enhancedGeojson, selectedMetric, isPerCapita]); // Recalculate when data, metric, or perCapita changes. - - /** - * Effect to dispatch the formatted bar chart data to the Redux store. - */ - useEffect(() => { - dispatch(setBarChartData(barChartData)); - }, [barChartData, dispatch]); // Dispatch when bar chart data changes. + .domain([0, d3.max(colorScaleValues.filter((v) => isFinite(v))) || 0]) + .interpolator(d3.interpolateBlues), + [colorScaleValues] + ); /** * Calculates the approximate centroid coordinates for a given county polygon/multipolygon. diff --git a/lib/features/filters/filterSlice.ts b/lib/features/filters/filterSlice.ts index 6b8f9d3..240c647 100644 --- a/lib/features/filters/filterSlice.ts +++ b/lib/features/filters/filterSlice.ts @@ -56,7 +56,6 @@ export interface FilterState { filteredData: CsvRow[]; yearRange: [number, number]; selectedMetric: string; // Use string for flexibility - rankedCounties: { name: string; value: number; rank: number }[]; selectedDataSource: DataSourceType; // Add selected data source isPerCapita: boolean; // Add per capita toggle state selectedCounties: string[]; // Add selected counties for filtering @@ -124,7 +123,6 @@ const initialState: FilterState = { filteredData: [], yearRange: [2017, 2023], // Default range, will be updated selectedMetric: DataSourceMetrics['arrest'][0], // Default metric for 'arrest' source - rankedCounties: [], selectedDataSource: 'arrest', // Default to the arrest source isPerCapita: true, // Default to true selectedCounties: [], // Initialize selected counties as empty array @@ -389,9 +387,6 @@ export const filterSlice = createSlice({ const currentSourceData = getCurrentSourceData(state); state.filteredData = applyFilters(currentSourceData, state.activeFilters, state.selectedDataSource, state.selectedCounties); }, - setRankedCounties: (state, action: PayloadAction<{ name: string; value: number; rank: number }[]>) => { - state.rankedCounties = action.payload; - }, setCsvData: (state, action: PayloadAction) => { // Store data in the current selected source slot state.csvDataSources[state.selectedDataSource] = action.payload; @@ -492,7 +487,6 @@ export const filterSlice = createSlice({ state.filteredData = applyFilters(newSourceData, state.activeFilters, action.payload, state.selectedCounties); // Reset UI state for new source - state.rankedCounties = []; // Update year range if we have data for this source if (newSourceData.length > 0) { @@ -579,7 +573,6 @@ export const filterSlice = createSlice({ }); export const { - setRankedCounties, setCsvData, toggleFilter, setYear, diff --git a/lib/features/map/mapSlice.ts b/lib/features/map/mapSlice.ts index f898358..2d06af9 100644 --- a/lib/features/map/mapSlice.ts +++ b/lib/features/map/mapSlice.ts @@ -1,15 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { EnhancedFeature } from '@/app/types/map'; export interface MapState { selectedCounty: string; - colorScaleValues: number[]; - barChartData: { county: string; value: number }[]; + enhancedGeojson: EnhancedFeature[]; } const initialState: MapState = { selectedCounty: '', - colorScaleValues: [], - barChartData: [], + enhancedGeojson: [], }; export const mapSlice = createSlice({ @@ -19,14 +18,11 @@ export const mapSlice = createSlice({ setSelectedCounty: (state, action: PayloadAction) => { state.selectedCounty = action.payload; }, - setColorScaleValues: (state, action: PayloadAction) => { - state.colorScaleValues = action.payload; - }, - setBarChartData: (state, action: PayloadAction<{ county: string; value: number }[]>) => { - state.barChartData = action.payload; + setEnhancedGeojson: (state, action: PayloadAction) => { + state.enhancedGeojson = action.payload; }, }, }); -export const { setSelectedCounty, setColorScaleValues, setBarChartData } = mapSlice.actions; +export const { setSelectedCounty, setEnhancedGeojson } = mapSlice.actions; export default mapSlice.reducer; diff --git a/lib/features/map/selectors.ts b/lib/features/map/selectors.ts new file mode 100644 index 0000000..e1bbe48 --- /dev/null +++ b/lib/features/map/selectors.ts @@ -0,0 +1,46 @@ +import { createSelector } from '@reduxjs/toolkit'; +import type { RootState } from '@/lib/store'; + +export const selectEnhancedGeojson = (state: RootState) => state.map.enhancedGeojson; +export const selectSelectedCounty = (state: RootState) => state.map.selectedCounty; + +const selectIsPerCapita = (state: RootState) => state.filters.isPerCapita; +const selectSelectedMetric = (state: RootState) => state.filters.selectedMetric; + +/** Numeric values for the current metric across all counties — used to build the color scale domain. */ +export const selectColorScaleValues = createSelector( + [selectEnhancedGeojson, selectSelectedMetric], + (geojson, metric) => + geojson + .map((f) => f.properties[metric] as unknown) + .filter((val): val is number => typeof val === 'number' && !isNaN(val)) +); + +/** Counties sorted descending by current metric value, each with a rank. */ +export const selectRankedCounties = createSelector( + [selectEnhancedGeojson, selectSelectedMetric], + (geojson, metric) => + geojson + .map((f) => ({ + name: f.properties.name, + value: f.properties[metric] as number, + rank: 0, + })) + .sort((a, b) => b.value - a.value) + .map((county, index) => ({ ...county, rank: index + 1 })) +); + +/** Bar chart data: county + display value (per-capita or raw), sorted descending. */ +export const selectBarChartData = createSelector( + [selectEnhancedGeojson, selectIsPerCapita], + (geojson, isPerCapita) => + geojson + .map((f) => ({ + county: f.properties.name, + value: + isPerCapita && f.properties.perCapitaValue !== undefined + ? (f.properties.perCapitaValue as number) + : (f.properties.rawValue as number), + })) + .sort((a, b) => b.value - a.value) +);