Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/components/widgets/BarChartWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
Expand Down
4 changes: 2 additions & 2 deletions app/components/widgets/CountyRank.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
106 changes: 13 additions & 93 deletions app/components/widgets/MapStory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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,
} from '@/lib/features/filters/filterSlice';
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';
Expand Down Expand Up @@ -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;
Expand All @@ -104,17 +106,13 @@ export default function MapStory() {
} | null>(null);
// State for managing the map's viewport (position, zoom, etc.).
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW_STATE);
// State to hold the numerical values used for the color scale (for legend/potential analysis).
const [colorValues, setColorValues] = useState<number[]>([]);
// State for expandable metric buttons
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);

// --- NEW Worker Related State ---
// --- Worker Related State ---
const workerRef = useRef<Worker | null>(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<EnhancedFeature[]>([]);
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);

// --- Effect to Initialize Worker ---
Expand All @@ -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
Expand Down Expand Up @@ -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<string>()
// 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.
Expand Down
7 changes: 0 additions & 7 deletions lib/features/filters/filterSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CsvRow[]>) => {
// Store data in the current selected source slot
state.csvDataSources[state.selectedDataSource] = action.payload;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -579,7 +573,6 @@ export const filterSlice = createSlice({
});

export const {
setRankedCounties,
setCsvData,
toggleFilter,
setYear,
Expand Down
16 changes: 6 additions & 10 deletions lib/features/map/mapSlice.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -19,14 +18,11 @@ export const mapSlice = createSlice({
setSelectedCounty: (state, action: PayloadAction<string>) => {
state.selectedCounty = action.payload;
},
setColorScaleValues: (state, action: PayloadAction<number[]>) => {
state.colorScaleValues = action.payload;
},
setBarChartData: (state, action: PayloadAction<{ county: string; value: number }[]>) => {
state.barChartData = action.payload;
setEnhancedGeojson: (state, action: PayloadAction<EnhancedFeature[]>) => {
state.enhancedGeojson = action.payload;
},
},
});

export const { setSelectedCounty, setColorScaleValues, setBarChartData } = mapSlice.actions;
export const { setSelectedCounty, setEnhancedGeojson } = mapSlice.actions;
export default mapSlice.reducer;
46 changes: 46 additions & 0 deletions lib/features/map/selectors.ts
Original file line number Diff line number Diff line change
@@ -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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Guard value against non-numeric or missing data before ranking counties.

If f.properties[metric] is missing or non-numeric, value becomes NaN, so b.value - a.value will also be NaN and the sort order undefined. Please either reuse the numeric filtering from selectColorScaleValues (e.g., filter to finite numbers before sorting) or coerce invalid values to a deterministic default (such as 0) before ranking.

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)
);