From 3cc2c67b31f0a2a3423e1d59cbf5f62a7d309d83 Mon Sep 17 00:00:00 2001 From: ztocode Date: Mon, 31 Mar 2025 13:24:39 -0400 Subject: [PATCH 1/6] add subregion community profile stackedbar charts --- src/components/CommunitySelectorView.jsx | 89 +++++- src/components/SubregionProfilesView.jsx | 270 ++++++++++++++++++ .../visualizations/ChartDetails.jsx | 37 ++- .../visualizations/StackedBarChart.js | 94 ++++-- src/main.jsx | 24 +- src/pages/CommunitySelectorPage.jsx | 112 ++++---- src/pages/SubregionProfilesPage.jsx | 21 ++ src/reducers/subregionSlice.js | 192 +++++++++++++ src/store.js | 2 + 9 files changed, 762 insertions(+), 79 deletions(-) create mode 100644 src/components/SubregionProfilesView.jsx create mode 100644 src/pages/SubregionProfilesPage.jsx create mode 100644 src/reducers/subregionSlice.js diff --git a/src/components/CommunitySelectorView.jsx b/src/components/CommunitySelectorView.jsx index 0577d9a..17eb2cc 100644 --- a/src/components/CommunitySelectorView.jsx +++ b/src/components/CommunitySelectorView.jsx @@ -1,21 +1,98 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; import MapBox from './MapBox'; import SearchBar from './partials/SearchBar'; +import { fetchSubregionData, selectSubregionData, selectSubregionLoading } from '../reducers/subregionSlice'; +const styles = { + subregionSelector: { + marginBottom: '1rem' + }, + select: { + width: '100%', + padding: '0.5rem', + border: '1px solid #ccc', + borderRadius: '4px', + backgroundColor: 'white', + fontSize: '14px', + height: '38px' + }, + selectFocus: { + outline: 'none', + borderColor: '#0066cc', + boxShadow: '0 0 0 2px rgba(0,102,204,0.2)' + } +}; + +const SUBREGIONS = { + 355: 'Inner Core Committee [ICC]', + 356: 'Minuteman Advisory Group on Interlocal Coordination [MAGIC]', + 357: 'MetroWest Regional Collaborative [MWRC]', + 358: 'North Shore Task Force [NSTF]', + 359: 'North Suburban Planning Council [NSPC]', + 360: 'South Shore Coalition [SSC]', + 361: 'South West Advisory Planning Committee [SWAP]', + 362: 'Three Rivers Interlocal Council [TRIC]' +}; const CommunitySelectorView = ({ muniLines, muniFill, municipalityPoly, toProfile }) => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const subregionData = useSelector(selectSubregionData); + const isLoading = useSelector(selectSubregionLoading); + const [selectedSubregion, setSelectedSubregion] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + dispatch(fetchSubregionData()); + }, [dispatch]); + + const handleSubregionChange = (event) => { + const subregionId = event.target.value; + setSelectedSubregion(subregionId); + if (subregionId) { + navigate(`/profile/subregion/${subregionId}`); + } + }; + + const handleMuniSelect = (muni) => { + if (selectedSubregion) { + navigate(`/profile/subregion/${selectedSubregion}/${muni.toLowerCase().replace(/\s+/g, '-')}`); + } else { + toProfile(muni); + } + }; + return (

Community Profiles

Search any community in Massachusetts to view their profile:

+
+ +
+ { - toProfile(muni.toLowerCase().replace(/\s+/g, '-')); - }} + onSelect={handleMuniSelect} placeholder={'Search for a community ...'} className={"small"} /> @@ -24,7 +101,9 @@ const CommunitySelectorView = ({ muniLines, muniFill, municipalityPoly, toProfil
); diff --git a/src/components/SubregionProfilesView.jsx b/src/components/SubregionProfilesView.jsx new file mode 100644 index 0000000..9d8e41d --- /dev/null +++ b/src/components/SubregionProfilesView.jsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import Tab from "./Tab"; +import Dropdown from "./field/Dropdown"; +import tabs from "../constants/tabs"; +import charts from "../constants/charts"; +import { selectSubregionData, selectMunicipalitiesBySubregion, selectSubregionChartData, fetchMissingMunicipalityData } from "../reducers/subregionSlice"; +import StackedBarChart from "../containers/visualizations/StackedBarChart"; +import StackedAreaChart from "../containers/visualizations/StackedAreaChart"; +import ChartDetails from "./visualizations/ChartDetails"; +import PieChart from "../containers/visualizations/PieChart"; +import LineChart from "../containers/visualizations/LineChart"; +import DownloadAllChartsButton from './field/DownloadAllChartsButton'; +import DataTableModal from './field/DataTableModal'; +import { createSelector } from '@reduxjs/toolkit'; + +const styles = { + municipalitiesList: { + display: 'flex', + flexWrap: 'wrap', + gap: '10px', + marginTop: '1rem', + marginBottom: '1rem' + }, + municipalityLink: { + color: '#0066cc', + textDecoration: 'none', + padding: '4px 8px', + borderRadius: '4px', + backgroundColor: '#f5f5f5', + fontSize: '14px', + '&:hover': { + backgroundColor: '#e5e5e5', + textDecoration: 'underline' + } + } +}; + +const SUBREGIONS = { + 355: 'Inner Core Committee [ICC]', + 356: 'Minuteman Advisory Group on Interlocal Coordination [MAGIC]', + 357: 'MetroWest Regional Collaborative [MWRC]', + 358: 'North Shore Task Force [NSTF]', + 359: 'North Suburban Planning Council [NSPC]', + 360: 'South Shore Coalition [SSC]', + 361: 'South West Advisory Planning Committee [SWAP]', + 362: 'Three Rivers Interlocal Council [TRIC]' +}; + +// Create base selectors +const selectChartState = state => state.chart.cache; +const selectSubregionState = state => state.subregion.data; + +// Create memoized selector for chart data +const makeChartDataSelector = (activeTab, subregionId) => createSelector( + [selectChartState, selectSubregionState], + (chartCache, subregionData) => { + if (!charts[activeTab]) { + return {}; + } + + // Create stable reference for each table's data + return Object.values(charts[activeTab]).reduce((acc, chart) => { + const tableName = Object.keys(chart.tables)[0]; + if (!acc[tableName]) { + acc[tableName] = []; + } + return acc; + }, {}); + } +); + +const SubregionProfilesView = () => { + const dispatch = useDispatch(); + const { subregionId, tab } = useParams(); + const [activeTab, setActiveTab] = useState(tab || 'demographics'); + const [modalConfig, setModalConfig] = useState({ + show: false, + data: null, + title: '' + }); + + // Create memoized selector instance + const chartDataSelector = React.useMemo( + () => makeChartDataSelector(activeTab, subregionId), + [activeTab, subregionId] + ); + + // Use memoized selector + const municipalityData = useSelector(chartDataSelector); + + const municipalities = useSelector( + state => state.subregion.data[subregionId]?.municipalities || [] + ); + + useEffect(() => { + setActiveTab(tab); + }, [tab]); + + // Effect for fetching municipality data + useEffect(() => { + if (!charts[activeTab] || !municipalities.length) return; + + Object.values(charts[activeTab]).forEach((chart) => { + dispatch(fetchMissingMunicipalityData({ + chartInfo: chart, + municipalities, + subregionId, + tableName: Object.keys(chart.tables)[0] + })); + }); + }, [activeTab, municipalities, subregionId, dispatch]); + + const handleShowModal = (data, title) => { + setModalConfig({ + show: true, + data: selectSubregionChartData(data,subregionId, title), //to do fix what is data + title: `${title} (Aggregated)` + }); + }; + + const handleCloseModal = () => { + setModalConfig({ + show: false, + data: null, + title: '' + }); + }; + + if (!subregionId || !SUBREGIONS[subregionId]) { + return
Subregion not found
; + } + + const renderCharts = () => { + if (!charts[activeTab]) return null; + + return Object.entries(charts[activeTab]).map(([key, chart]) => { + let ChartComponent; + switch (chart.type) { + case 'stacked-bar': + ChartComponent = StackedBarChart; + break; + case 'stacked-area': + ChartComponent = StackedAreaChart; + break; + case 'pie': + ChartComponent = PieChart; + break; + case 'line': + ChartComponent = LineChart; + break; + default: + ChartComponent = StackedBarChart; + } + + return ( + + + + ); + }); + }; + + return ( +
+
+
+ {"< Back"} +
+
+
+

{SUBREGIONS[subregionId]}

+
+
+
+

+ This subregion contains {municipalities.length} municipalities. The charts below show aggregated data for all municipalities in this subregion. +

+
+ {municipalities.map(muni => ( + + {muni.muni_name} + + ))} +
+
+ + +
+
+
+
+
+ +
+
+
    + {tabs.map((tabItem) => ( +
  • + setActiveTab(tabItem.value)} + > + {tabItem.label} + +
  • + ))} +
+
+ setActiveTab(e.target.value)} + /> +
+
+ +
+
+ {tabs.map((tabItem) => ( + +
+

{tabItem.label}

+
+
+ {activeTab === tabItem.value && renderCharts()} +
+
+ ))} +
+
+
+ + +
+ ); +}; + +export default React.memo(SubregionProfilesView); \ No newline at end of file diff --git a/src/components/visualizations/ChartDetails.jsx b/src/components/visualizations/ChartDetails.jsx index 2dee7ea..1600a72 100644 --- a/src/components/visualizations/ChartDetails.jsx +++ b/src/components/visualizations/ChartDetails.jsx @@ -45,7 +45,7 @@ const makeSelectChartData = (tables, muni) => createSelector( }), {}) ); -const ChartDetails = ({ chart, children, muni, onViewData }) => { +const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { const [timeframe, setTimeframe] = useState(typeof chart.timeframe === 'string' ? chart.timeframe : 'Unknown'); const selectChartData = React.useMemo( @@ -57,6 +57,20 @@ const ChartDetails = ({ chart, children, muni, onViewData }) => { const tableName = Object.keys(chartData)[0]; const data = chartData[tableName]; + // Add selector for subregion cache + const selectSubregionCache = createSelector( + [(state) => state.subregion.cache], + (cache) => { + if (isSubregion) { + const tableName = Object.keys(chart.tables)[0]; + return cache[tableName]?.[muni] || []; + } + return []; + } + ); + + const subregionCache = useSelector(selectSubregionCache); + useEffect(() => { if (typeof chart.timeframe === 'function') { chart.timeframe().then(setTimeframe); @@ -64,7 +78,12 @@ const ChartDetails = ({ chart, children, muni, onViewData }) => { }, [chart.timeframe]); const handleViewData = () => { - onViewData(data, chart.title); + if (isSubregion) { + // Use the cached aggregated data from subregion state + onViewData(subregionCache, chart.title); + } else { + onViewData(data, chart.title); + } }; return ( @@ -72,15 +91,20 @@ const ChartDetails = ({ chart, children, muni, onViewData }) => { {chart.title || 'Chart Title'} + {isSubregion && ' (Aggregated)'} View Data - + {children} @@ -130,6 +154,11 @@ ChartDetails.propTypes = { }).isRequired, muni: PropTypes.string.isRequired, onViewData: PropTypes.func.isRequired, + isSubregion: PropTypes.bool, +}; + +ChartDetails.defaultProps = { + isSubregion: false }; export default ChartDetails; diff --git a/src/containers/visualizations/StackedBarChart.js b/src/containers/visualizations/StackedBarChart.js index 536c9d2..384d5f0 100644 --- a/src/containers/visualizations/StackedBarChart.js +++ b/src/containers/visualizations/StackedBarChart.js @@ -1,5 +1,19 @@ import { connect } from 'react-redux'; import StackedBarChart from '../../components/visualizations/StackedBarChart'; +import { selectSubregionChartData } from '../../reducers/subregionSlice'; +import { createSelector } from '@reduxjs/toolkit'; + +// Import SUBREGIONS constant +const SUBREGIONS = { + 355: 'Inner Core Committee [ICC]', + 356: 'Minuteman Advisory Group on Interlocal Coordination [MAGIC]', + 357: 'MetroWest Regional Collaborative [MWRC]', + 358: 'North Shore Task Force [NSTF]', + 359: 'North Suburban Planning Council [NSPC]', + 360: 'South Shore Coalition [SSC]', + 361: 'South West Advisory Planning Committee [SWAP]', + 362: 'Three Rivers Interlocal Council [TRIC]' +}; function valuesHaveData(transformedData) { const checkData = transformedData.reduce((acc, row) => { @@ -17,29 +31,69 @@ function valuesHaveData(transformedData) { return false; } -const mapStateToProps = (state, props) => { - const { muni, chart } = props; - const tables = Object.keys(chart.tables); - if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { - const muniTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.chart.cache[table][muni] }), {}); - return { - ...props, - xAxis: chart.xAxis, - yAxis: chart.yAxis, - data: chart.transformer(muniTables, chart), - hasData: valuesHaveData(chart.transformer(muniTables, chart)), - }; +// Memoize the data transformation logic +const selectChartData = createSelector( + [ + (state, props) => props.isSubregion, + (state, props) => props.muni, + (state, props) => props.chart, + (state) => state.chart.cache, + (state, props) => state + ], + (isSubregion, muni, chart, cache, state) => { + const tables = Object.keys(chart.tables); + + if (isSubregion) { + // Handle subregion data + const subregionId = Object.entries(SUBREGIONS).find( + ([id, name]) => name === muni + )?.[0]; + + if (!subregionId) return { data: [], hasData: false }; + + const subregionData = tables.map(table => + selectSubregionChartData(state, table, subregionId, chart) + ); + console.log("subregionData", subregionData); + // Check if we have any data + if (subregionData.some(data => data && data.length > 0)) { + const transformedData = chart.transformer( + { [tables[0]]: subregionData[0] }, + chart + ); + + return { + data: transformedData, + hasData: valuesHaveData(transformedData) + }; + } + } else { + // Handle municipality data + if (tables.every((table) => cache[table] && cache[table][muni])) { + const muniTables = tables.reduce((acc, table) => + Object.assign(acc, { [table]: cache[table][muni] }), {}); + + const transformedData = chart.transformer(muniTables, chart); + return { + data: transformedData, + hasData: valuesHaveData(transformedData) + }; + } + } + + return { data: [], hasData: false }; } +); + +const mapStateToProps = (state, props) => { + const { xAxis, yAxis } = props.chart; + const chartData = selectChartData(state, props); + return { ...props, - xAxis: { - label: '', - }, - yAxis: { - label: '', - }, - data: [], - hasData: false, + xAxis, + yAxis, + ...chartData // spread data and hasData }; }; diff --git a/src/main.jsx b/src/main.jsx index a4d8c83..6de9c7c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,6 +12,7 @@ import CalenderEntry from "./components/gallery/CalendarEntry"; import store from "./store"; import "../src/assets/styles/app.scss"; import CommunityProfilesPage from "./pages/CommunityProfilesPage"; +import SubregionProfilesPage from "./pages/SubregionProfilesPage"; import tabs from "./constants/tabs"; import municipalities from "./assets/data/ma-munis.json"; @@ -22,6 +23,8 @@ const muniOptions = municipalities.features.map( const tabOptions = tabs.map(tab => tab.value); +const VALID_SUBREGIONS = ['355', '356', '357', '358', '359', '360', '361', '362']; + const ProfileRoute = ({ muniOptions, tabOptions }) => { const { muni, tab } = useParams(); @@ -36,6 +39,20 @@ const ProfileRoute = ({ muniOptions, tabOptions }) => { return ; }; +const SubregionProfileRoute = ({ tabOptions }) => { + const { subregionId, tab } = useParams(); + + if (!VALID_SUBREGIONS.includes(subregionId)) { + return ; + } + + if (!tab || !tabOptions.includes(tab)) { + return ; + } + + return ; +}; + const router = createBrowserRouter([ { path: "/", @@ -73,7 +90,12 @@ const router = createBrowserRouter([ { path: "/profile/:muni/:tab?", element: - },{ + }, + { + path: "/profile/subregion/:subregionId/:tab?", + element: + }, + { path:"gallery", children:[ { diff --git a/src/pages/CommunitySelectorPage.jsx b/src/pages/CommunitySelectorPage.jsx index 927b68c..0a2f6a0 100644 --- a/src/pages/CommunitySelectorPage.jsx +++ b/src/pages/CommunitySelectorPage.jsx @@ -1,77 +1,91 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { createSelector } from '@reduxjs/toolkit'; import CommunitySelectorView from '../components/CommunitySelectorView'; import { fillPoly, emptyPoly } from '../reducers/municipalitySlice'; -// Container component that handles data and logic -const CommunitySelectorPage = () => { - const dispatch = useDispatch(); - const navigate = useNavigate(); - - const { municipality, search } = useSelector(state => ({ - municipality: state.municipality, - search: state.search.municipality - })); +// Memoized selectors +const selectMunicipalityState = state => state.municipality; +const selectSearchState = state => state.search.municipality; - // Process map data - const munisPoly = { ...municipality.geojson }; - const { results, hovering } = search; +const selectProcessedMapData = createSelector( + [selectMunicipalityState, selectSearchState], + (municipality, search) => { + const munisPoly = { ...municipality.geojson }; + const { results, hovering } = search; - const lineFeatures = results.length - ? { - ...munisPoly, - features: munisPoly.features.filter((feature) => - !results.length || - results.indexOf(feature.properties.town.toLowerCase()) > -1 - ), - } - : munisPoly; + const lineFeatures = results.length + ? { + ...munisPoly, + features: munisPoly.features.filter((feature) => + !results.length || + results.indexOf(feature.properties.town.toLowerCase()) > -1 + ), + } + : munisPoly; - const muniLines = { - type: 'line', - geojson: lineFeatures, - }; + const muniLines = { + type: 'line', + geojson: lineFeatures, + }; - let muniFill = { - type: 'fill', - geojson: { ...munisPoly, features: [] }, - }; + let muniFill = { + type: 'fill', + geojson: { ...munisPoly, features: [] }, + }; - if (hovering) { - const upperHovering = hovering.toUpperCase(); - let filledMuniIndex = null; + if (hovering) { + const upperHovering = hovering.toUpperCase(); + let filledMuniIndex = null; - munisPoly.features.some((feature, i) => { - if (feature.properties.town === upperHovering) { - filledMuniIndex = i; - return true; - } - return false; - }); + munisPoly.features.some((feature, i) => { + if (feature.properties.town === upperHovering) { + filledMuniIndex = i; + return true; + } + return false; + }); - if (filledMuniIndex !== null) { - muniFill.geojson = { - ...munisPoly, - features: [munisPoly.features[filledMuniIndex]], - }; + if (filledMuniIndex !== null) { + muniFill.geojson = { + ...munisPoly, + features: [munisPoly.features[filledMuniIndex]], + }; + } } + + return { + muniLines, + muniFill, + municipalityPoly: munisPoly + }; } +); + +// Container component that handles data and logic +const CommunitySelectorPage = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + // Use memoized selector + const { muniLines, muniFill, municipalityPoly } = useSelector(selectProcessedMapData); - const handleMunicipalitySelect = (municipality) => { + // Memoize handler + const handleMunicipalitySelect = useMemo(() => (municipality) => { const formattedMuni = municipality.toLowerCase().replace(/\s+/g, '-'); dispatch(fillPoly(formattedMuni)); navigate(`/profile/${formattedMuni}`); - }; + }, [dispatch, navigate]); return ( ); }; -export default CommunitySelectorPage; \ No newline at end of file +export default React.memo(CommunitySelectorPage); \ No newline at end of file diff --git a/src/pages/SubregionProfilesPage.jsx b/src/pages/SubregionProfilesPage.jsx new file mode 100644 index 0000000..1e0b1af --- /dev/null +++ b/src/pages/SubregionProfilesPage.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import SubregionProfilesView from '../components/SubregionProfilesView'; + +const SubregionProfilesPage = () => { + const { subregionId } = useParams(); + + // Get subregion data from Redux store + const subregionData = useSelector(state => state.subregion.data[subregionId]); + + if (!subregionData) { + return
Subregion not found
; + } + + return ( + + ); +}; + +export default SubregionProfilesPage; \ No newline at end of file diff --git a/src/reducers/subregionSlice.js b/src/reducers/subregionSlice.js new file mode 100644 index 0000000..7fdca57 --- /dev/null +++ b/src/reducers/subregionSlice.js @@ -0,0 +1,192 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import locations from "../constants/locations"; +import { fetchChartData } from "./chartSlice"; + +// Helper function to aggregate chart data for a subregion +const aggregateChartData = (chartData, municipalities) => { + if (!chartData || !municipalities || municipalities.length === 0) return []; + + const aggregatedByYear = {}; + + municipalities.forEach(muni => { + const muniData = chartData[muni.muni_name.toLowerCase().replace(/\s+/g, '-')] || []; + + muniData.forEach(row => { + const yearColumn = Object.keys(row).find(k => + k.toLowerCase() === 'fy' || k.toLowerCase().includes('year') + ); + + if (!yearColumn) return; + + const yearValue = row[yearColumn]; + + if (!aggregatedByYear[yearValue]) { + aggregatedByYear[yearValue] = { + [yearColumn]: yearValue + }; + } + + Object.entries(row).forEach(([key, value]) => { + if (key !== yearColumn && typeof value === 'number') { + aggregatedByYear[yearValue][key] = (aggregatedByYear[yearValue][key] || 0) + value; + } else if (key !== yearColumn && !aggregatedByYear[yearValue][key]) { + aggregatedByYear[yearValue][key] = value; + } + }); + }); + }); + + return Object.values(aggregatedByYear); +}; + +export const fetchSubregionData = createAsyncThunk( + "subregion/fetchData", + async () => { + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=`; + const query = ` + SELECT + muni_id, + muni_name, + subrg_id, + subrg_acr + FROM tabular._datakeys_muni_all + WHERE subrg_id IS NOT NULL + ORDER BY subrg_id, muni_name + `; + + const response = await fetch(`${api}${encodeURIComponent(query)}`); + const payload = await response.json(); + + // Transform the data into the desired structure + const subregionMap = {}; + + payload.rows?.forEach(row => { + const { muni_id, muni_name, subrg_id } = row; + + if (!subregionMap[subrg_id]) { + subregionMap[subrg_id] = { + municipalities: [], + totalMunis: 0 + }; + } + + // Add municipality data + subregionMap[subrg_id].municipalities.push({ + muni_name, + muni_id + }); + + subregionMap[subrg_id].totalMunis = subregionMap[subrg_id].municipalities.length; + }); + + return subregionMap; + } +); + +// New thunk to fetch missing municipality data +export const fetchMissingMunicipalityData = createAsyncThunk( + "subregion/fetchMissingData", + async ({ chartInfo, municipalities }, { dispatch, getState }) => { + const state = getState(); + const tableName = Object.keys(chartInfo.tables)[0]; + + // Check each municipality and fetch if data is missing + for (const muni of municipalities) { + const muniSlug = muni.muni_name.toLowerCase().replace(/\s+/g, '-'); + const existingData = state.chart.cache[tableName]?.[muniSlug]; + + if (!existingData || existingData.length === 0) { + await dispatch(fetchChartData({ + chartInfo, + municipality: muniSlug + })); + } + } + + return true; + } +); + +export const updateSubregionChart = createAsyncThunk( + "subregion/updateChart", + async ({ tableName, subregionId, data }) => { + return { tableName, subregionId, data }; + } +); + +const subregionSlice = createSlice({ + name: "subregion", + initialState: { + data: {}, + loading: false, + error: null, + cache: {}, + dataFetchStatus: {}, // Track fetch status for each subregion-table combination + }, + reducers: { + updateSubregionChart: (state, action) => { + const { tableName, subregionName, data } = action.payload; + if (!tableName || !subregionName || !data) { + return; + } + if (!state.cache[tableName]) { + state.cache[tableName] = {}; + } + state.cache[tableName][subregionName] = data; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchSubregionData.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchSubregionData.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + }) + .addCase(fetchSubregionData.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message; + }) + .addCase(fetchMissingMunicipalityData.fulfilled, (state, action) => { + // Mark this subregion-table combination as fetched + const { subregionId, tableName } = action.meta.arg; + if (!state.dataFetchStatus[subregionId]) { + state.dataFetchStatus[subregionId] = {}; + } + state.dataFetchStatus[subregionId][tableName] = true; + }); + }, +}); + +// Selectors +export const selectSubregionData = (state) => state.subregion.data; +export const selectSubregionLoading = (state) => state.subregion.loading; +export const selectSubregionError = (state) => state.subregion.error; +export const selectMunicipalitiesBySubregion = (state, subregionId) => + state.subregion.data[subregionId]?.municipalities || []; + +// Enhanced selector for chart data that handles aggregation +export const selectSubregionChartData = (state, tableName, subregionId, chartInfo) => { + const municipalities = selectMunicipalitiesBySubregion(state, subregionId); + const chartData = {}; + let hasAllData = true; + + // First check if we have data for all municipalities + municipalities.forEach(muni => { + const muniSlug = muni.muni_name.toLowerCase().replace(/\s+/g, '-'); + const muniData = state.chart.cache[tableName]?.[muniSlug]; + + if (!muniData || muniData.length === 0) { + hasAllData = false; + } else { + chartData[muniSlug] = muniData; + } + }); + + // Only aggregate if we have data for all municipalities + return hasAllData ? aggregateChartData(chartData, municipalities) : []; +}; + +export default subregionSlice.reducer; \ No newline at end of file diff --git a/src/store.js b/src/store.js index f567f52..92ceee8 100644 --- a/src/store.js +++ b/src/store.js @@ -3,6 +3,7 @@ import datasetReducer from './reducers/datasetSlice'; import searchReducer from './reducers/searchSlice'; import municipalityReducer from './reducers/municipalitySlice'; import chartReducer from './reducers/chartSlice'; +import subregionReducer from './reducers/subregionSlice'; export const store = configureStore({ reducer: { @@ -10,6 +11,7 @@ export const store = configureStore({ search: searchReducer, municipality: municipalityReducer, chart: chartReducer, + subregion: subregionReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ From 376013b1cf13601cf1f79a5bfaf7c6d66bdf0434 Mon Sep 17 00:00:00 2001 From: ztocode Date: Tue, 1 Apr 2025 13:50:15 -0400 Subject: [PATCH 2/6] add download charts for subregion and store subregion data in store --- src/components/SubregionProfilesView.jsx | 11 +++--- src/components/field/DownloadChartButton.jsx | 33 ++++++++++++++-- .../visualizations/ChartDetails.jsx | 1 + .../visualizations/StackedBarChart.jsx | 10 +++++ .../visualizations/StackedBarChart.js | 39 ++++++++++++++++--- src/reducers/subregionSlice.js | 17 +++----- 6 files changed, 86 insertions(+), 25 deletions(-) diff --git a/src/components/SubregionProfilesView.jsx b/src/components/SubregionProfilesView.jsx index 9d8e41d..77fe39c 100644 --- a/src/components/SubregionProfilesView.jsx +++ b/src/components/SubregionProfilesView.jsx @@ -111,14 +111,14 @@ const SubregionProfilesView = () => { })); }); }, [activeTab, municipalities, subregionId, dispatch]); - - const handleShowModal = (data, title) => { + + const handleShowModal = (data, title) =>{ setModalConfig({ show: true, - data: selectSubregionChartData(data,subregionId, title), //to do fix what is data + data: data, title: `${title} (Aggregated)` }); - }; + } const handleCloseModal = () => { setModalConfig({ @@ -134,7 +134,7 @@ const SubregionProfilesView = () => { const renderCharts = () => { if (!charts[activeTab]) return null; - + return Object.entries(charts[activeTab]).map(([key, chart]) => { let ChartComponent; switch (chart.type) { @@ -160,6 +160,7 @@ const SubregionProfilesView = () => { chart={chart} muni={subregionId} onViewData={handleShowModal} + isSubregion={true} > createSelector( [(state) => state.chart.cache], @@ -28,7 +38,7 @@ const makeSelectChartData = (tables, muni) => createSelector( }), {}) ); -export default function DownloadChartButton({ chart, muni }) { +export default function DownloadChartButton({ chart, muni, isSubregion }) { const selectChartData = React.useMemo( () => makeSelectChartData(Object.keys(chart.tables), muni), [chart.tables, muni] @@ -36,10 +46,24 @@ export default function DownloadChartButton({ chart, muni }) { const chartData = useSelector(selectChartData); + // Add selector for subregion cache + const selectSubregionCache = createSelector( + [(state) => state.subregion.cache], + (cache) => { + if (isSubregion) { + const tableName = Object.keys(chart.tables)[0]; + return cache[tableName]?.[muni] || []; + } + return []; + } + ); + + const subregionCache = useSelector(selectSubregionCache); + const downloadCsv = () => { try { const tableName = Object.keys(chartData)[0]; - const data = chartData[tableName]; + const data = isSubregion ? subregionCache : chartData[tableName]; if (!data || data.length === 0) { console.error('No data available for the selected municipality.'); @@ -48,8 +72,9 @@ export default function DownloadChartButton({ chart, muni }) { // Convert data to CSV const headers = Object.keys(data[0]); + const firstRow = isSubregion ? ['Subregion:', SUBREGIONS[muni] ] : ['Municipality:', muni].join(','); const csv = [ - ['Municipality:', muni].join(','), + firstRow, headers.join(','), ...data.map(row => headers.map(header => { @@ -65,7 +90,7 @@ export default function DownloadChartButton({ chart, muni }) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.setAttribute('download', `${chart.title}_${muni}.csv`); + link.setAttribute('download', `${chart.title}_${isSubregion ? SUBREGIONS[muni] : muni}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); diff --git a/src/components/visualizations/ChartDetails.jsx b/src/components/visualizations/ChartDetails.jsx index 1600a72..7a51cc3 100644 --- a/src/components/visualizations/ChartDetails.jsx +++ b/src/components/visualizations/ChartDetails.jsx @@ -82,6 +82,7 @@ const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { // Use the cached aggregated data from subregion state onViewData(subregionCache, chart.title); } else { + console.log("data= ", data); onViewData(data, chart.title); } }; diff --git a/src/components/visualizations/StackedBarChart.jsx b/src/components/visualizations/StackedBarChart.jsx index 3f01074..e9149f3 100644 --- a/src/components/visualizations/StackedBarChart.jsx +++ b/src/components/visualizations/StackedBarChart.jsx @@ -39,6 +39,7 @@ const StackedBarChart = (props) => { const [xAxisLabel, setXAxisLabel] = useState( typeof props.xAxis.label === 'string' ? props.xAxis.label : '' ); + const hasCached = useRef(false); useEffect(() => { const loadXAxisLabel = async () => { @@ -93,6 +94,13 @@ const StackedBarChart = (props) => { }; }, []); + useEffect(() => { + if (props.cacheData && !hasCached.current) { + props.cacheSubregionData(props.cacheData); + hasCached.current = true; + } + }, [props.cacheData, props.cacheSubregionData]); + const renderChart = () => { const chart = chartGroupRef.current; const tooltip = tooltipRef.current; @@ -394,6 +402,8 @@ StackedBarChart.propTypes = { wrapLeftLabel: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, + cacheData: PropTypes.object, + cacheSubregionData: PropTypes.func }; export default StackedBarChart; \ No newline at end of file diff --git a/src/containers/visualizations/StackedBarChart.js b/src/containers/visualizations/StackedBarChart.js index 384d5f0..e1f61d6 100644 --- a/src/containers/visualizations/StackedBarChart.js +++ b/src/containers/visualizations/StackedBarChart.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import StackedBarChart from '../../components/visualizations/StackedBarChart'; -import { selectSubregionChartData } from '../../reducers/subregionSlice'; +import { selectSubregionChartData, updateSubregionChart } from '../../reducers/subregionSlice'; import { createSelector } from '@reduxjs/toolkit'; // Import SUBREGIONS constant @@ -51,10 +51,23 @@ const selectChartData = createSelector( if (!subregionId) return { data: [], hasData: false }; + // Check if data is already cached + const isDataCached = state.subregion.cache[tables[0]]?.[subregionId]; + if (isDataCached) { + const transformedData = chart.transformer( + { [tables[0]]: isDataCached }, + chart + ); + return { + data: transformedData, + hasData: valuesHaveData(transformedData) + }; + } + const subregionData = tables.map(table => selectSubregionChartData(state, table, subregionId, chart) ); - console.log("subregionData", subregionData); + // Check if we have any data if (subregionData.some(data => data && data.length > 0)) { const transformedData = chart.transformer( @@ -64,7 +77,13 @@ const selectChartData = createSelector( return { data: transformedData, - hasData: valuesHaveData(transformedData) + hasData: valuesHaveData(transformedData), + // Only pass cacheData if it's not already cached + cacheData: { + tableName: tables[0], + subregionId, + data: subregionData[0] + } }; } } else { @@ -93,11 +112,21 @@ const mapStateToProps = (state, props) => { ...props, xAxis, yAxis, - ...chartData // spread data and hasData + ...chartData }; }; -const mapDispatchToProps = (dispatch, props) => ({}); +const mapDispatchToProps = (dispatch) => ({ + cacheSubregionData: (cacheData) => { + if (cacheData?.tableName && cacheData?.subregionId && cacheData?.data) { + dispatch(updateSubregionChart({ + tableName: cacheData.tableName, + subregionId: cacheData.subregionId, + data: cacheData.data + })); + } + } +}); export default connect(mapStateToProps, mapDispatchToProps)(StackedBarChart); export { valuesHaveData }; diff --git a/src/reducers/subregionSlice.js b/src/reducers/subregionSlice.js index 7fdca57..b230706 100644 --- a/src/reducers/subregionSlice.js +++ b/src/reducers/subregionSlice.js @@ -107,13 +107,6 @@ export const fetchMissingMunicipalityData = createAsyncThunk( } ); -export const updateSubregionChart = createAsyncThunk( - "subregion/updateChart", - async ({ tableName, subregionId, data }) => { - return { tableName, subregionId, data }; - } -); - const subregionSlice = createSlice({ name: "subregion", initialState: { @@ -125,14 +118,16 @@ const subregionSlice = createSlice({ }, reducers: { updateSubregionChart: (state, action) => { - const { tableName, subregionName, data } = action.payload; - if (!tableName || !subregionName || !data) { + console.log("update reducer call= ", action.payload); + const { tableName, subregionId, data } = action.payload; + if (!tableName || !subregionId || !data) { return; } if (!state.cache[tableName]) { state.cache[tableName] = {}; } - state.cache[tableName][subregionName] = data; + console.log("updateSubregionChart= ", tableName, subregionId, data); + state.cache[tableName][subregionId] = data; }, }, extraReducers: (builder) => { @@ -188,5 +183,5 @@ export const selectSubregionChartData = (state, tableName, subregionId, chartIn // Only aggregate if we have data for all municipalities return hasAllData ? aggregateChartData(chartData, municipalities) : []; }; - +export const { updateSubregionChart } = subregionSlice.actions; export default subregionSlice.reducer; \ No newline at end of file From 9fe14b44f25b8a6934679923fae2a12fa2c41126 Mon Sep 17 00:00:00 2001 From: ztocode Date: Thu, 3 Apr 2025 14:14:02 -0400 Subject: [PATCH 3/6] add subregion community profiles --- src/components/SubregionProfilesView.jsx | 516 +++++++++++++----- src/components/visualizations/LineChart.jsx | 9 +- src/components/visualizations/PieChart.jsx | 8 +- .../visualizations/StackedAreaChart.jsx | 16 +- .../visualizations/StackedBarChart.jsx | 11 +- src/constants/charts.js | 246 +++++++++ src/containers/visualizations/LineChart.js | 36 +- src/containers/visualizations/PieChart.js | 64 ++- .../visualizations/StackedAreaChart.js | 77 ++- .../visualizations/StackedBarChart.js | 137 ++--- src/main.jsx | 4 + src/reducers/chartSlice.js | 1 + src/reducers/subregionSlice.js | 204 ++++--- 13 files changed, 935 insertions(+), 394 deletions(-) diff --git a/src/components/SubregionProfilesView.jsx b/src/components/SubregionProfilesView.jsx index 77fe39c..290a346 100644 --- a/src/components/SubregionProfilesView.jsx +++ b/src/components/SubregionProfilesView.jsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; +import styled from 'styled-components'; import Tab from "./Tab"; import Dropdown from "./field/Dropdown"; import tabs from "../constants/tabs"; import charts from "../constants/charts"; -import { selectSubregionData, selectMunicipalitiesBySubregion, selectSubregionChartData, fetchMissingMunicipalityData } from "../reducers/subregionSlice"; +import { fetchSubregionChartData, fetchSubregionData, selectSubregionData } from "../reducers/subregionSlice"; import StackedBarChart from "../containers/visualizations/StackedBarChart"; import StackedAreaChart from "../containers/visualizations/StackedAreaChart"; import ChartDetails from "./visualizations/ChartDetails"; @@ -13,29 +14,94 @@ import PieChart from "../containers/visualizations/PieChart"; import LineChart from "../containers/visualizations/LineChart"; import DownloadAllChartsButton from './field/DownloadAllChartsButton'; import DataTableModal from './field/DataTableModal'; -import { createSelector } from '@reduxjs/toolkit'; - -const styles = { - municipalitiesList: { - display: 'flex', - flexWrap: 'wrap', - gap: '10px', - marginTop: '1rem', - marginBottom: '1rem' - }, - municipalityLink: { - color: '#0066cc', - textDecoration: 'none', - padding: '4px 8px', - borderRadius: '4px', - backgroundColor: '#f5f5f5', - fontSize: '14px', - '&:hover': { - backgroundColor: '#e5e5e5', - textDecoration: 'underline' + +// Styled Components +const MunicipalitiesList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 1rem; + margin-bottom: 1rem; + height: 100px; + overflow-y: auto; + padding: 10px; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #555; + } +`; + +const MunicipalitiesRow = styled.div` + display: flex; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 35px; +`; + +const MunicipalityLinkWrapper = styled.div` + flex: 0 0 calc((100% - 72px) / 10); /* (100% - (9 * 8px gaps)) / 10 items */ + min-width: 90px; +`; + +const StyledLink = styled(Link)` + color: #0066cc; + text-decoration: none; + padding: 6px 24px 6px 8px; + border-radius: 4px; + background-color: #f5f5f5; + font-size: 13px; + white-space: nowrap; + border: 1px solid #e0e0e0; + transition: all 0.2s ease; + text-align: center; + width: 100%; + height: 35px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + + &::after { + content: "↗"; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + opacity: 0.7; + } + + &:hover { + background-color: #e5e5e5; + text-decoration: underline; + border-color: #ccc; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + &::after { + opacity: 1; } } -}; +`; const SUBREGIONS = { 355: 'Inner Core Committee [ICC]', @@ -48,28 +114,13 @@ const SUBREGIONS = { 362: 'Three Rivers Interlocal Council [TRIC]' }; -// Create base selectors -const selectChartState = state => state.chart.cache; -const selectSubregionState = state => state.subregion.data; - -// Create memoized selector for chart data -const makeChartDataSelector = (activeTab, subregionId) => createSelector( - [selectChartState, selectSubregionState], - (chartCache, subregionData) => { - if (!charts[activeTab]) { - return {}; - } - - // Create stable reference for each table's data - return Object.values(charts[activeTab]).reduce((acc, chart) => { - const tableName = Object.keys(chart.tables)[0]; - if (!acc[tableName]) { - acc[tableName] = []; - } - return acc; - }, {}); +const chunkArray = (array, size) => { + const chunked = []; + for (let i = 0; i < array.length; i += size) { + chunked.push(array.slice(i, i + size)); } -); + return chunked; +}; const SubregionProfilesView = () => { const dispatch = useDispatch(); @@ -81,36 +132,28 @@ const SubregionProfilesView = () => { title: '' }); - // Create memoized selector instance - const chartDataSelector = React.useMemo( - () => makeChartDataSelector(activeTab, subregionId), - [activeTab, subregionId] - ); - - // Use memoized selector - const municipalityData = useSelector(chartDataSelector); - - const municipalities = useSelector( - state => state.subregion.data[subregionId]?.municipalities || [] - ); + const subregionData = useSelector(selectSubregionData); + const municipalities = subregionData[subregionId]?.municipalities || []; useEffect(() => { setActiveTab(tab); }, [tab]); - // Effect for fetching municipality data + // Effect for fetching subregion data useEffect(() => { - if (!charts[activeTab] || !municipalities.length) return; - - Object.values(charts[activeTab]).forEach((chart) => { - dispatch(fetchMissingMunicipalityData({ - chartInfo: chart, - municipalities, - subregionId, - tableName: Object.keys(chart.tables)[0] - })); - }); - }, [activeTab, municipalities, subregionId, dispatch]); + dispatch(fetchSubregionData()); + }, [dispatch]); + + // Effect for fetching chart data + useEffect(() => { + if (charts[activeTab]) { + Object.values(charts[activeTab]).forEach((chart) => + dispatch(fetchSubregionChartData({ subregionId: subregionId, chartInfo: chart })) + ); + } + }, [activeTab, subregionId, dispatch]); + + const handleShowModal = (data, title) =>{ setModalConfig({ @@ -132,47 +175,6 @@ const SubregionProfilesView = () => { return
Subregion not found
; } - const renderCharts = () => { - if (!charts[activeTab]) return null; - - return Object.entries(charts[activeTab]).map(([key, chart]) => { - let ChartComponent; - switch (chart.type) { - case 'stacked-bar': - ChartComponent = StackedBarChart; - break; - case 'stacked-area': - ChartComponent = StackedAreaChart; - break; - case 'pie': - ChartComponent = PieChart; - break; - case 'line': - ChartComponent = LineChart; - break; - default: - ChartComponent = StackedBarChart; - } - - return ( - - - - ); - }); - }; - return (
@@ -188,17 +190,23 @@ const SubregionProfilesView = () => {

This subregion contains {municipalities.length} municipalities. The charts below show aggregated data for all municipalities in this subregion.

-
- {municipalities.map(muni => ( - - {muni.muni_name} - + + {chunkArray(municipalities, 10).map((row, rowIndex) => ( + + {row.map(muni => ( + + + {muni.muni_name} + + + ))} + ))} -
+
@@ -262,7 +520,7 @@ const SubregionProfilesView = () => { data={modalConfig.data} title={modalConfig.title} muni={subregionId} - isSubregion + isSubregion={true} />
); diff --git a/src/components/visualizations/LineChart.jsx b/src/components/visualizations/LineChart.jsx index 9272872..540d748 100644 --- a/src/components/visualizations/LineChart.jsx +++ b/src/components/visualizations/LineChart.jsx @@ -319,28 +319,29 @@ class LineChart extends React.Component { LineChart.propTypes = { xAxis: PropTypes.shape({ label: PropTypes.string.isRequired, + format: PropTypes.func, min: PropTypes.number, max: PropTypes.number, ticks: PropTypes.number, - format: PropTypes.func, }).isRequired, yAxis: PropTypes.shape({ label: PropTypes.string.isRequired, + format: PropTypes.func, min: PropTypes.number, max: PropTypes.number, ticks: PropTypes.number, - format: PropTypes.func, }).isRequired, data: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string.isRequired, + values: PropTypes.arrayOf(PropTypes.array).isRequired, color: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)).isRequired, }) ).isRequired, - hasData: PropTypes.bool.isRequired, + hasData: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, + isSubregion: PropTypes.bool, }; export default LineChart; diff --git a/src/components/visualizations/PieChart.jsx b/src/components/visualizations/PieChart.jsx index c182cb9..c567b66 100644 --- a/src/components/visualizations/PieChart.jsx +++ b/src/components/visualizations/PieChart.jsx @@ -204,13 +204,17 @@ class PieChart extends React.Component { } PieChart.propTypes = { - colors: PropTypes.arrayOf(PropTypes.string), data: PropTypes.arrayOf( PropTypes.shape({ - value: PropTypes.number.isRequired, label: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, }) ).isRequired, + colors: PropTypes.arrayOf(PropTypes.string), + hasData: PropTypes.bool, + width: PropTypes.number, + height: PropTypes.number, + isSubregion: PropTypes.bool, }; export default PieChart; diff --git a/src/components/visualizations/StackedAreaChart.jsx b/src/components/visualizations/StackedAreaChart.jsx index 4ba8ddf..8b7fec8 100644 --- a/src/components/visualizations/StackedAreaChart.jsx +++ b/src/components/visualizations/StackedAreaChart.jsx @@ -271,25 +271,27 @@ class StackedAreaChart extends React.Component { } StackedAreaChart.propTypes = { - data: PropTypes.arrayOf( - PropTypes.shape({ - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, - z: PropTypes.string.isRequired, - }) - ).isRequired, xAxis: PropTypes.shape({ label: PropTypes.string.isRequired, format: PropTypes.func, + ticks: PropTypes.number, }).isRequired, yAxis: PropTypes.shape({ label: PropTypes.string.isRequired, format: PropTypes.func, }).isRequired, + data: PropTypes.arrayOf( + PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + z: PropTypes.string.isRequired, + }) + ).isRequired, colors: PropTypes.arrayOf(PropTypes.string), hasData: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, + isSubregion: PropTypes.bool, }; StackedAreaChart.defaultProps = { diff --git a/src/components/visualizations/StackedBarChart.jsx b/src/components/visualizations/StackedBarChart.jsx index e9149f3..5930076 100644 --- a/src/components/visualizations/StackedBarChart.jsx +++ b/src/components/visualizations/StackedBarChart.jsx @@ -39,7 +39,6 @@ const StackedBarChart = (props) => { const [xAxisLabel, setXAxisLabel] = useState( typeof props.xAxis.label === 'string' ? props.xAxis.label : '' ); - const hasCached = useRef(false); useEffect(() => { const loadXAxisLabel = async () => { @@ -94,13 +93,6 @@ const StackedBarChart = (props) => { }; }, []); - useEffect(() => { - if (props.cacheData && !hasCached.current) { - props.cacheSubregionData(props.cacheData); - hasCached.current = true; - } - }, [props.cacheData, props.cacheSubregionData]); - const renderChart = () => { const chart = chartGroupRef.current; const tooltip = tooltipRef.current; @@ -402,8 +394,7 @@ StackedBarChart.propTypes = { wrapLeftLabel: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, - cacheData: PropTypes.object, - cacheSubregionData: PropTypes.func + isSubregion: PropTypes.bool, }; export default StackedBarChart; \ No newline at end of file diff --git a/src/constants/charts.js b/src/constants/charts.js index 79a0e3a..bb41f68 100644 --- a/src/constants/charts.js +++ b/src/constants/charts.js @@ -124,6 +124,26 @@ export default { [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + select + acs_year, + nhwhi, + nhaa, + nhna, + nhas, + nhpi, + nhoth, + nhmlt, + lat + from + tabular.b03002_race_ethnicity_acs_m + where muni_id = '${subregionId}' + AND acs_year = ( SELECT MAX(acs_year) + FROM tabular.b03002_race_ethnicity_acs_m) + `; + return queryString; + }, }, pop_by_age: { type: "stacked-bar", @@ -189,6 +209,32 @@ export default { z: chart.labels[k], })); }, + subregionDataQuery: (subregionId) => { + const queryString = `SELECT + years, + totpop, + pop_u18, + pop18_24, + pop25_34, + pop35_39, + pop40_44, + pop45_49, + pop50_54, + pop55_59, + pop60_61, + pop62_64, + pop65_66, + pop67_69, + pop70_74, + pop75_79, + pop80_84, + pop85o + FROM tabular.census2010_p12_pop_by_age_m + WHERE muni_id = '${subregionId}' + and years = (SELECT MAX(years) from tabular.census2010_p12_pop_by_age_m) + `; + return queryString; + }, }, }, economy: { @@ -247,6 +293,30 @@ export default { [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + acs_year, + SUM(emp) AS emp, + SUM(unemp) AS unemp + FROM tabular.b23025_employment_acs_m + WHERE muni_id = '${subregionId}' + AND acs_year IN ( + SELECT DISTINCT acs_year + FROM tabular.b23025_employment_acs_m + WHERE muni_id IN ( + SELECT muni_id + FROM tabular._datakeys_muni_all + WHERE subrg_id = ${subregionId} + ) + ORDER BY acs_year DESC + LIMIT 2 + ) + GROUP BY acs_year + ORDER BY acs_year DESC; + `; + return queryString; + }, }, emp_by_sector: { type: "stacked-area", @@ -333,6 +403,20 @@ export default { }, []); return data; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + cal_year, + naicstitle, + naicscode, + avgemp + FROM tabular.econ_es202_naics_2d_m + WHERE muni_id = '${subregionId}' + AND naicstitle IS NOT NULL + ORDER BY cal_year, naicstitle; + `; + return queryString; + }, }, }, education: { @@ -434,6 +518,11 @@ export default { ); return data; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + `; + return queryString; + }, }, edu_attainment_by_race: { type: "stacked-bar", @@ -552,6 +641,58 @@ export default { [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + acs_year, + nhwlh, + nhwhs, + nhwsc, + nhwbd, + aalh, + aahs, + aasc, + aabd, + nalh, + nahs, + nasc, + nabd, + aslh, + ashs, + assc, + asbd, + pilh, + pihs, + pisc, + pibd, + othlh, + othhs, + othsc, + othbd, + mltlh, + mlths, + mltsc, + mltbd, + latlh, + laths, + latsc, + latbd + FROM tabular.c15002_educational_attainment_by_race_acs_m + WHERE muni_id = '${subregionId}' + AND acs_year IN ( + SELECT DISTINCT acs_year + FROM tabular.c15002_educational_attainment_by_race_acs_m + WHERE muni_id IN ( + SELECT muni_id + FROM tabular._datakeys_muni_all + WHERE subrg_id = ${subregionId} + ) + ORDER BY acs_year DESC + ) + ORDER BY acs_year asc + `; + return queryString; + }, }, }, governance: { @@ -617,6 +758,30 @@ export default { label: chart.labels[key], })); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + fy, + res_taxes, + os_taxes, + comm_taxes, + ind_taxes, + p_prop_tax, + tot_rev + FROM tabular.econ_municipal_taxes_revenue_m + WHERE muni_id = '${subregionId}' + AND fy = ( + SELECT MAX(fy) + FROM tabular.econ_municipal_taxes_revenue_m + WHERE muni_id IN ( + SELECT muni_id + FROM tabular._datakeys_muni_all + WHERE subrg_id = ${subregionId} + ) + ) + `; + return queryString; + }, }, }, environment: { @@ -677,6 +842,10 @@ export default { }, ]; }, + subregionDataQuery: (subregionId) => { + const queryString = ``; + return queryString; + }, }, energy_usage_gas: { type: "stacked-area", @@ -731,8 +900,16 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; ]), [] ); + console.log("energy_usage_gas", data); return data; }, + subregionDataQuery: (subregionId) => { + const queryString1 = ` + `; + const queryString2 = ` + `; + return [queryString1, queryString2]; + } }, energy_usage_electricity: { type: "stacked-area", @@ -789,6 +966,13 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; [] ); }, + subregionDataQuery: (subregionId) => { + const queryString1 = ` + `; + const queryString2 = ` + `; + return [queryString1, queryString2]; + }, }, }, housing: { @@ -876,6 +1060,29 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; }, ]; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + acs_year, + occv2, + cb, + o_notcb, + r_notcb, + ocb3050, + rcb3050, + cb_3050, + o_cb50, + r_cb50, + cb_50 + FROM tabular.b25091_b25070_costburden_acs_m + WHERE muni_id = '${subregionId}' + AND acs_year = ( + SELECT MAX(acs_year) + FROM tabular.b25091_b25070_costburden_acs_m + ) + `; + return queryString; + }, }, units_permitted: { type: "stacked-area", @@ -950,6 +1157,10 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ``; + return queryString; + }, }, }, "public-health": { @@ -1047,6 +1258,11 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + `; + return queryString; + }, }, hospitalizations: { type: "stacked-bar", @@ -1150,6 +1366,11 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; ); return []; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + `; + return queryString; + }, }, }, transportation: { @@ -1207,6 +1428,10 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ``; + return queryString; + }, }, commute_to_work: { type: "pie", @@ -1259,6 +1484,27 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; label: chart.labels[key], })); }, + subregionDataQuery: (subregionId) => { + const queryString = ` + SELECT + acs_year, + ctvsngl, + carpool, + pub, + taxi, + mcycle, + bicycle, + walk, + other + FROM tabular.b08301_means_transportation_to_work_by_residence_acs_m + WHERE muni_id = '${subregionId}' + AND acs_year = ( + SELECT MAX(acs_year) + FROM tabular.b08301_means_transportation_to_work_by_residence_acs_m + ) + `; + return queryString; + }, }, }, }; diff --git a/src/containers/visualizations/LineChart.js b/src/containers/visualizations/LineChart.js index 7e241a1..ac228b4 100644 --- a/src/containers/visualizations/LineChart.js +++ b/src/containers/visualizations/LineChart.js @@ -12,11 +12,40 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion } = props; const tables = Object.keys(chart.tables); - if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { - // Create a new object for muniTables with spread operator + // Handle subregion data + if (isSubregion) { + if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { + const subregionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.subregion.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(subregionTables, chart); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming subregion data:', error); + return { + ...props, + xAxis: { label: '' }, + yAxis: { label: '' }, + data: [], + hasData: false, + }; + } + } + } + // Handle regular municipality data + else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { const muniTables = tables.reduce((acc, table) => ({ ...acc, [table]: state.chart.cache[table][muni] @@ -24,7 +53,6 @@ const mapStateToProps = (state, props) => { try { const transformedData = chart.transformer(muniTables, chart); - return { ...props, xAxis: chart.xAxis, diff --git a/src/containers/visualizations/PieChart.js b/src/containers/visualizations/PieChart.js index fccfb4f..2887a8f 100644 --- a/src/containers/visualizations/PieChart.js +++ b/src/containers/visualizations/PieChart.js @@ -18,24 +18,60 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion } = props; const tables = Object.keys(chart.tables); - if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { - // Create muniTables while preserving original functionality - const muniTables = tables.reduce((acc, table) => - Object.assign({}, acc, { - [table]: state.chart.cache[table][muni] + // Handle subregion data + if (isSubregion) { + if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { + const subregionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.subregion.cache[table][muni] }), {}); - const transformedData = chart.transformer(muniTables, chart); - - return { - ...props, - xAxis: chart.xAxis, - data: transformedData, - hasData: valuesHaveData(transformedData), - }; + try { + const transformedData = chart.transformer(subregionTables, chart); + return { + ...props, + xAxis: chart.xAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming subregion data:', error); + return { + ...props, + xAxis: { format: (d) => d }, + data: [], + hasData: false, + }; + } + } + } + // Handle regular municipality data + else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { + const muniTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.chart.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(muniTables, chart); + return { + ...props, + xAxis: chart.xAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming data:', error); + return { + ...props, + xAxis: { format: (d) => d }, + data: [], + hasData: false, + }; + } } return { diff --git a/src/containers/visualizations/StackedAreaChart.js b/src/containers/visualizations/StackedAreaChart.js index 66c83db..9ae8dd7 100644 --- a/src/containers/visualizations/StackedAreaChart.js +++ b/src/containers/visualizations/StackedAreaChart.js @@ -18,26 +18,71 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion } = props; const tables = Object.keys(chart.tables); - if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { - const muniTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.chart.cache[table][muni] }), {}); - return { - ...props, - xAxis: chart.xAxis, - yAxis: chart.yAxis, - data: chart.transformer(muniTables, chart), - hasData: valuesHaveData(chart.transformer(muniTables, chart)), - }; + + // Handle subregion data + if (isSubregion) { + if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { + const subregionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.subregion.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(subregionTables, chart); + console.log(transformedData); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming subregion data:', error); + return { + ...props, + xAxis: { label: '' }, + yAxis: { label: '' }, + data: [], + hasData: false, + }; + } + } } + // Handle regular municipality data + else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { + const muniTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.chart.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(muniTables, chart); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming data:', error); + return { + ...props, + xAxis: { label: '' }, + yAxis: { label: '' }, + data: [], + hasData: false, + }; + } + } + return { ...props, - xAxis: { - label: '', - }, - yAxis: { - label: '', - }, + xAxis: { label: '' }, + yAxis: { label: '' }, data: [], hasData: false, }; diff --git a/src/containers/visualizations/StackedBarChart.js b/src/containers/visualizations/StackedBarChart.js index e1f61d6..5bdab7e 100644 --- a/src/containers/visualizations/StackedBarChart.js +++ b/src/containers/visualizations/StackedBarChart.js @@ -1,19 +1,5 @@ import { connect } from 'react-redux'; import StackedBarChart from '../../components/visualizations/StackedBarChart'; -import { selectSubregionChartData, updateSubregionChart } from '../../reducers/subregionSlice'; -import { createSelector } from '@reduxjs/toolkit'; - -// Import SUBREGIONS constant -const SUBREGIONS = { - 355: 'Inner Core Committee [ICC]', - 356: 'Minuteman Advisory Group on Interlocal Coordination [MAGIC]', - 357: 'MetroWest Regional Collaborative [MWRC]', - 358: 'North Shore Task Force [NSTF]', - 359: 'North Suburban Planning Council [NSPC]', - 360: 'South Shore Coalition [SSC]', - 361: 'South West Advisory Planning Committee [SWAP]', - 362: 'Three Rivers Interlocal Council [TRIC]' -}; function valuesHaveData(transformedData) { const checkData = transformedData.reduce((acc, row) => { @@ -31,102 +17,49 @@ function valuesHaveData(transformedData) { return false; } -// Memoize the data transformation logic -const selectChartData = createSelector( - [ - (state, props) => props.isSubregion, - (state, props) => props.muni, - (state, props) => props.chart, - (state) => state.chart.cache, - (state, props) => state - ], - (isSubregion, muni, chart, cache, state) => { - const tables = Object.keys(chart.tables); - - if (isSubregion) { - // Handle subregion data - const subregionId = Object.entries(SUBREGIONS).find( - ([id, name]) => name === muni - )?.[0]; - - if (!subregionId) return { data: [], hasData: false }; - - // Check if data is already cached - const isDataCached = state.subregion.cache[tables[0]]?.[subregionId]; - if (isDataCached) { - const transformedData = chart.transformer( - { [tables[0]]: isDataCached }, - chart - ); - return { - data: transformedData, - hasData: valuesHaveData(transformedData) - }; - } - - const subregionData = tables.map(table => - selectSubregionChartData(state, table, subregionId, chart) - ); +const mapStateToProps = (state, props) => { + const { muni, chart, isSubregion } = props; + const tables = Object.keys(chart.tables); - // Check if we have any data - if (subregionData.some(data => data && data.length > 0)) { - const transformedData = chart.transformer( - { [tables[0]]: subregionData[0] }, - chart - ); - - return { - data: transformedData, - hasData: valuesHaveData(transformedData), - // Only pass cacheData if it's not already cached - cacheData: { - tableName: tables[0], - subregionId, - data: subregionData[0] - } - }; - } - } else { - // Handle municipality data - if (tables.every((table) => cache[table] && cache[table][muni])) { - const muniTables = tables.reduce((acc, table) => - Object.assign(acc, { [table]: cache[table][muni] }), {}); - - const transformedData = chart.transformer(muniTables, chart); - return { - data: transformedData, - hasData: valuesHaveData(transformedData) - }; - } + // Handle subregion data + if (isSubregion) { + if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { + const subregionTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.subregion.cache[table][muni] }), {}); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: chart.transformer(subregionTables, chart), + hasData: valuesHaveData(chart.transformer(subregionTables, chart)), + }; } - - return { data: [], hasData: false }; + } + // Handle regular municipality data + else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { + const muniTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.chart.cache[table][muni] }), {}); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: chart.transformer(muniTables, chart), + hasData: valuesHaveData(chart.transformer(muniTables, chart)), + }; } -); - -const mapStateToProps = (state, props) => { - const { xAxis, yAxis } = props.chart; - const chartData = selectChartData(state, props); return { ...props, - xAxis, - yAxis, - ...chartData + xAxis: { + label: '', + }, + yAxis: { + label: '', + }, + data: [], + hasData: false, }; }; -const mapDispatchToProps = (dispatch) => ({ - cacheSubregionData: (cacheData) => { - if (cacheData?.tableName && cacheData?.subregionId && cacheData?.data) { - dispatch(updateSubregionChart({ - tableName: cacheData.tableName, - subregionId: cacheData.subregionId, - data: cacheData.data - })); - } - } -}); +const mapDispatchToProps = (dispatch, props) => ({}); export default connect(mapStateToProps, mapDispatchToProps)(StackedBarChart); -export { valuesHaveData }; +export { valuesHaveData }; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 6de9c7c..c127de1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -50,6 +50,10 @@ const SubregionProfileRoute = ({ tabOptions }) => { return ; } + if (!subregionId || !VALID_SUBREGIONS.includes(subregionId)) { + return
Subregion not found
; + } + return ; }; diff --git a/src/reducers/chartSlice.js b/src/reducers/chartSlice.js index ee8ed1f..0c2e730 100644 --- a/src/reducers/chartSlice.js +++ b/src/reducers/chartSlice.js @@ -94,6 +94,7 @@ const chartSlice = createSlice({ state.cache[table] = {}; } state.cache[table][muni] = data; + console.log("updateChart= ", table, muni, data); }, }, extraReducers: (builder) => { diff --git a/src/reducers/subregionSlice.js b/src/reducers/subregionSlice.js index b230706..58ac77b 100644 --- a/src/reducers/subregionSlice.js +++ b/src/reducers/subregionSlice.js @@ -1,43 +1,92 @@ import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; import locations from "../constants/locations"; -import { fetchChartData } from "./chartSlice"; -// Helper function to aggregate chart data for a subregion -const aggregateChartData = (chartData, municipalities) => { - if (!chartData || !municipalities || municipalities.length === 0) return []; +//fetch subregion chart data +export const fetchSubregionChartData = createAsyncThunk( + "subregion/fetchChartData", + async ({ subregionId, chartInfo }, { dispatch, getState }) => { + const { subregion } = getState(); + const tableNames = Object.keys(chartInfo.tables); - const aggregatedByYear = {}; - - municipalities.forEach(muni => { - const muniData = chartData[muni.muni_name.toLowerCase().replace(/\s+/g, '-')] || []; - - muniData.forEach(row => { - const yearColumn = Object.keys(row).find(k => - k.toLowerCase() === 'fy' || k.toLowerCase().includes('year') + const dispatchUpdate = (data, tableName) => { + dispatch( + updateSubregionChart({ + table: tableName, + muni: subregionId, + data, + }) ); - - if (!yearColumn) return; - - const yearValue = row[yearColumn]; - - if (!aggregatedByYear[yearValue]) { - aggregatedByYear[yearValue] = { - [yearColumn]: yearValue - }; + }; + + // Get all queries first + const queries = chartInfo.subregionDataQuery(subregionId); + + // Handle multiple tables - match each table with its corresponding query + if (tableNames.length > 1) { + // Make sure we have the same number of queries as tables + if (queries.length !== tableNames.length) { + console.error("Mismatch between number of tables and queries"); + return; } - - Object.entries(row).forEach(([key, value]) => { - if (key !== yearColumn && typeof value === 'number') { - aggregatedByYear[yearValue][key] = (aggregatedByYear[yearValue][key] || 0) + value; - } else if (key !== yearColumn && !aggregatedByYear[yearValue][key]) { - aggregatedByYear[yearValue][key] = value; + + // Process each table with its corresponding query + for (let i = 0; i < tableNames.length; i++) { + const tableName = tableNames[i]; + const query = queries[i]; + let { years } = chartInfo.tables[tableName]; + + if (subregion.cache[tableName]?.[subregionId]) { + continue; } - }); - }); - }); - return Object.values(aggregatedByYear); -}; + if (typeof years === "function") { + try { + const yearsResult = await years(); + years = yearsResult; + } catch (error) { + console.error("Error executing years function:", error); + continue; + } + } + + try { + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=${query}`; + const response = await fetch(api); + const payload = (await response.json()) || {}; + dispatchUpdate(payload.rows, tableName); + } catch (error) { + console.error(`Error fetching data for table ${tableName}:`, error); + } + } + return; + } + + // Handle single table + const tableName = tableNames[0]; + let { years } = chartInfo.tables[tableName]; + + if (typeof years === "function") { + try { + const yearsResult = await years(); + years = yearsResult; + } catch (error) { + console.error("Error executing years function:", error); + return; + } + } + + try { + // For single table, use the first (and should be only) query + const query = Array.isArray(queries) ? queries[0] : queries; + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=${query}`; + const response = await fetch(api); + const payload = (await response.json()) || {}; + dispatchUpdate(payload.rows, tableName); + } catch (error) { + console.error(`Error fetching data for table ${tableName}:`, error); + } + } +); export const fetchSubregionData = createAsyncThunk( "subregion/fetchData", @@ -53,57 +102,34 @@ export const fetchSubregionData = createAsyncThunk( WHERE subrg_id IS NOT NULL ORDER BY subrg_id, muni_name `; - + const response = await fetch(`${api}${encodeURIComponent(query)}`); const payload = await response.json(); - + // Transform the data into the desired structure const subregionMap = {}; - - payload.rows?.forEach(row => { + + payload.rows?.forEach((row) => { const { muni_id, muni_name, subrg_id } = row; - + if (!subregionMap[subrg_id]) { subregionMap[subrg_id] = { municipalities: [], - totalMunis: 0 + totalMunis: 0, }; } - + // Add municipality data subregionMap[subrg_id].municipalities.push({ muni_name, - muni_id + muni_id, }); - subregionMap[subrg_id].totalMunis = subregionMap[subrg_id].municipalities.length; + subregionMap[subrg_id].totalMunis = + subregionMap[subrg_id].municipalities.length; }); - - return subregionMap; - } -); -// New thunk to fetch missing municipality data -export const fetchMissingMunicipalityData = createAsyncThunk( - "subregion/fetchMissingData", - async ({ chartInfo, municipalities }, { dispatch, getState }) => { - const state = getState(); - const tableName = Object.keys(chartInfo.tables)[0]; - - // Check each municipality and fetch if data is missing - for (const muni of municipalities) { - const muniSlug = muni.muni_name.toLowerCase().replace(/\s+/g, '-'); - const existingData = state.chart.cache[tableName]?.[muniSlug]; - - if (!existingData || existingData.length === 0) { - await dispatch(fetchChartData({ - chartInfo, - municipality: muniSlug - })); - } - } - - return true; + return subregionMap; } ); @@ -113,20 +139,20 @@ const subregionSlice = createSlice({ data: {}, loading: false, error: null, - cache: {}, - dataFetchStatus: {}, // Track fetch status for each subregion-table combination + cache: {} }, reducers: { updateSubregionChart: (state, action) => { - console.log("update reducer call= ", action.payload); - const { tableName, subregionId, data } = action.payload; + const { table: tableName, muni: subregionId, data } = action.payload; // Fix destructuring + if (!tableName || !subregionId || !data) { return; } + if (!state.cache[tableName]) { state.cache[tableName] = {}; } - console.log("updateSubregionChart= ", tableName, subregionId, data); + state.cache[tableName][subregionId] = data; }, }, @@ -143,45 +169,11 @@ const subregionSlice = createSlice({ .addCase(fetchSubregionData.rejected, (state, action) => { state.loading = false; state.error = action.error.message; - }) - .addCase(fetchMissingMunicipalityData.fulfilled, (state, action) => { - // Mark this subregion-table combination as fetched - const { subregionId, tableName } = action.meta.arg; - if (!state.dataFetchStatus[subregionId]) { - state.dataFetchStatus[subregionId] = {}; - } - state.dataFetchStatus[subregionId][tableName] = true; }); }, }); -// Selectors export const selectSubregionData = (state) => state.subregion.data; export const selectSubregionLoading = (state) => state.subregion.loading; -export const selectSubregionError = (state) => state.subregion.error; -export const selectMunicipalitiesBySubregion = (state, subregionId) => - state.subregion.data[subregionId]?.municipalities || []; - -// Enhanced selector for chart data that handles aggregation -export const selectSubregionChartData = (state, tableName, subregionId, chartInfo) => { - const municipalities = selectMunicipalitiesBySubregion(state, subregionId); - const chartData = {}; - let hasAllData = true; - - // First check if we have data for all municipalities - municipalities.forEach(muni => { - const muniSlug = muni.muni_name.toLowerCase().replace(/\s+/g, '-'); - const muniData = state.chart.cache[tableName]?.[muniSlug]; - - if (!muniData || muniData.length === 0) { - hasAllData = false; - } else { - chartData[muniSlug] = muniData; - } - }); - - // Only aggregate if we have data for all municipalities - return hasAllData ? aggregateChartData(chartData, municipalities) : []; -}; export const { updateSubregionChart } = subregionSlice.actions; -export default subregionSlice.reducer; \ No newline at end of file +export default subregionSlice.reducer; From e5a910888de1ac567e840571e6f8c3f8f7ae66c0 Mon Sep 17 00:00:00 2001 From: ztocode Date: Mon, 7 Apr 2025 09:47:24 -0400 Subject: [PATCH 4/6] add rpa region --- src/components/CommunitySelectorView.jsx | 48 +- src/components/RPAregionProfilesView.jsx | 506 ++++++++++++++++++ src/components/SubregionProfilesView.jsx | 15 +- .../field/DownloadAllChartsButton.jsx | 77 ++- src/components/field/DownloadChartButton.jsx | 34 +- .../visualizations/ChartDetails.jsx | 21 +- .../visualizations/StackedBarChart.jsx | 1 + src/constants/charts.js | 199 ++++--- src/containers/visualizations/LineChart.js | 45 +- src/containers/visualizations/PieChart.js | 34 +- .../visualizations/StackedAreaChart.js | 45 +- .../visualizations/StackedBarChart.js | 17 +- src/main.jsx | 22 +- src/pages/RPAregionProfilesPage.jsx | 33 ++ src/pages/SubregionProfilesPage.jsx | 33 +- src/reducers/rparegionSlice.js | 187 +++++++ src/store.js | 2 + 17 files changed, 1172 insertions(+), 147 deletions(-) create mode 100644 src/components/RPAregionProfilesView.jsx create mode 100644 src/pages/RPAregionProfilesPage.jsx create mode 100644 src/reducers/rparegionSlice.js diff --git a/src/components/CommunitySelectorView.jsx b/src/components/CommunitySelectorView.jsx index 17eb2cc..b95b8e8 100644 --- a/src/components/CommunitySelectorView.jsx +++ b/src/components/CommunitySelectorView.jsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import MapBox from './MapBox'; import SearchBar from './partials/SearchBar'; import { fetchSubregionData, selectSubregionData, selectSubregionLoading } from '../reducers/subregionSlice'; +import { fetchRPAregionData, selectRPAregionData, selectRPAregionLoading } from '../reducers/rparegionSlice'; const styles = { subregionSelector: { @@ -37,29 +38,52 @@ const SUBREGIONS = { 362: 'Three Rivers Interlocal Council [TRIC]' }; +const RPAREGIONS = { + 352:'MAPC', + 402:'Central Massachusetts', + 403:'Northeastern Massachusetts', + 404:'Southeastern Massachusetts', + 405:'Western Massachusetts' +}; + const CommunitySelectorView = ({ muniLines, muniFill, municipalityPoly, toProfile }) => { const navigate = useNavigate(); const dispatch = useDispatch(); const subregionData = useSelector(selectSubregionData); + const rparegionData = useSelector(selectRPAregionData); const isLoading = useSelector(selectSubregionLoading); const [selectedSubregion, setSelectedSubregion] = useState(''); + const [selectedRPAregion, setSelectedRPAregion] = useState(''); const [isFocused, setIsFocused] = useState(false); useEffect(() => { dispatch(fetchSubregionData()); + dispatch(fetchRPAregionData()); }, [dispatch]); const handleSubregionChange = (event) => { const subregionId = event.target.value; setSelectedSubregion(subregionId); + setSelectedRPAregion(''); if (subregionId) { navigate(`/profile/subregion/${subregionId}`); } }; + const handleRPAregionChange = (event) => { + const rpaId = event.target.value; + setSelectedRPAregion(rpaId); + setSelectedSubregion(''); + if (rpaId) { + navigate(`/profile/rpa/${rpaId}`); + } + }; + const handleMuniSelect = (muni) => { if (selectedSubregion) { navigate(`/profile/subregion/${selectedSubregion}/${muni.toLowerCase().replace(/\s+/g, '-')}`); + } else if (selectedRPAregion) { + navigate(`/profile/rparegion/${selectedRPAregion}/${muni.toLowerCase().replace(/\s+/g, '-')}`); } else { toProfile(muni); } @@ -83,13 +107,33 @@ const CommunitySelectorView = ({ muniLines, muniFill, municipalityPoly, toProfil }} disabled={isLoading} > - + {Object.entries(SUBREGIONS).map(([id, name]) => ( ))} + +
+ +
+ ); diff --git a/src/components/RPAregionProfilesView.jsx b/src/components/RPAregionProfilesView.jsx new file mode 100644 index 0000000..2ac7fac --- /dev/null +++ b/src/components/RPAregionProfilesView.jsx @@ -0,0 +1,506 @@ +import React, { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import styled from 'styled-components'; +import Tab from "./Tab"; +import Dropdown from "./field/Dropdown"; +import tabs from "../constants/tabs"; +import charts from "../constants/charts"; +import { selectRPAregionData,fetchRPAregionChartData } from "../reducers/rparegionSlice"; +import StackedBarChart from "../containers/visualizations/StackedBarChart"; +import StackedAreaChart from "../containers/visualizations/StackedAreaChart"; +import ChartDetails from "./visualizations/ChartDetails"; +import PieChart from "../containers/visualizations/PieChart"; +import LineChart from "../containers/visualizations/LineChart"; +import DownloadAllChartsButton from './field/DownloadAllChartsButton'; +import DataTableModal from './field/DataTableModal'; + +// Styled Components +const MunicipalitiesList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 1rem; + margin-bottom: 1rem; + height: 100px; + overflow-y: auto; + padding: 10px; + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 6px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #555; + } +`; + +const MunicipalitiesRow = styled.div` + display: flex; + gap: 8px; + flex: 0 0 auto; + width: 100%; + min-height: 35px; +`; + +const MunicipalityLinkWrapper = styled.div` + flex: 0 0 calc((100% - 72px) / 10); /* (100% - (9 * 8px gaps)) / 10 items */ + min-width: 90px; +`; + +const StyledLink = styled(Link)` + color: #0066cc; + text-decoration: none; + padding: 6px 24px 6px 8px; + border-radius: 4px; + background-color: #f5f5f5; + font-size: 13px; + white-space: nowrap; + border: 1px solid #e0e0e0; + transition: all 0.2s ease; + text-align: center; + width: 100%; + height: 35px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + + &::after { + content: "↗"; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 12px; + opacity: 0.7; + } + + &:hover { + background-color: #e5e5e5; + text-decoration: underline; + border-color: #ccc; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + &::after { + opacity: 1; + } + } +`; + +const chunkArray = (array, size) => { + const chunked = []; + for (let i = 0; i < array.length; i += size) { + chunked.push(array.slice(i, i + size)); + } + return chunked; +}; + +const RPAregionProfilesView = () => { + const dispatch = useDispatch(); + const { rpaId, tab } = useParams(); + const [activeTab, setActiveTab] = useState(tab || 'demographics'); + const [modalConfig, setModalConfig] = useState({ + show: false, + data: null, + title: '' + }); + + const rparegionData = useSelector(selectRPAregionData); + const municipalities = rparegionData[rpaId]?.municipalities || []; + const rpa_name = rparegionData[rpaId]?.rpa_name || ''; + + + + // Effect for fetching chart data + useEffect(() => { + if (charts[activeTab]) { + Object.values(charts[activeTab]).forEach((chart) => + dispatch(fetchRPAregionChartData({ rpa_id: rpaId, chartInfo: chart })) + ); + } + }, [activeTab, rpaId, dispatch]); + + const handleShowModal = (data, title) => { + setModalConfig({ + show: true, + data: data, + title: `${title} (Aggregated)` + }); + } + + const handleCloseModal = () => { + setModalConfig({ + show: false, + data: null, + title: '' + }); + }; + + return ( +
+
+
+ {"< Back"} +
+
+
+

{rpa_name}

+
+
+
+

+ This RPA region contains {municipalities.length} municipalities. The charts below show aggregated data for all municipalities in this region. +

+ + {chunkArray(municipalities, 10).map((row, rowIndex) => ( + + {row.map(muni => ( + + + {muni.muni_name} + + + ))} + + ))} + +
+ + +
+
+
+
+
+ +
+
+
    + {tabs.map((tabItem) => ( +
  • + setActiveTab(tabItem.value)} + > + {tabItem.label} + +
  • + ))} +
+
+ setActiveTab(e.target.value)} + /> +
+
+ +
+
+ +
+

Demographics

+
+
+ + + + + + +
+
+ + +
+

Economy

+
+
+ + + + + + +
+
+ + +
+

Education

+
+
+ + + + + + +
+
+ + +
+

Governance

+
+
+ + + +
+
+ + +
+

Environment

+
+
+ + + + + + +
+
+ + + +
+
+ + +
+

Housing

+
+
+ + + + + + +
+
+ + +
+

Public Health

+
+
+ + + + + + +
+
+ + +
+

Transportation

+
+
+ + + + + + +
+
+
+
+
+ + +
+ ); +}; + +export default React.memo(RPAregionProfilesView); \ No newline at end of file diff --git a/src/components/SubregionProfilesView.jsx b/src/components/SubregionProfilesView.jsx index 290a346..60a2c82 100644 --- a/src/components/SubregionProfilesView.jsx +++ b/src/components/SubregionProfilesView.jsx @@ -139,11 +139,6 @@ const SubregionProfilesView = () => { setActiveTab(tab); }, [tab]); - // Effect for fetching subregion data - useEffect(() => { - dispatch(fetchSubregionData()); - }, [dispatch]); - // Effect for fetching chart data useEffect(() => { if (charts[activeTab]) { @@ -153,9 +148,7 @@ const SubregionProfilesView = () => { } }, [activeTab, subregionId, dispatch]); - - - const handleShowModal = (data, title) =>{ + const handleShowModal = (data, title) => { setModalConfig({ show: true, data: data, @@ -171,10 +164,6 @@ const SubregionProfilesView = () => { }); }; - if (!subregionId || !SUBREGIONS[subregionId]) { - return
Subregion not found
; - } - return (
@@ -215,7 +204,7 @@ const SubregionProfilesView = () => { > Print charts - +
diff --git a/src/components/field/DownloadAllChartsButton.jsx b/src/components/field/DownloadAllChartsButton.jsx index afa0fcf..9336f86 100644 --- a/src/components/field/DownloadAllChartsButton.jsx +++ b/src/components/field/DownloadAllChartsButton.jsx @@ -6,6 +6,8 @@ import * as XLSX from "xlsx"; import charts from "../../constants/charts"; import PropTypes from "prop-types"; import { fetchChartData } from "../../reducers/chartSlice"; +import { fetchSubregionChartData } from "../../reducers/subregionSlice"; +import { fetchRPAregionChartData } from "../../reducers/rparegionSlice"; import { store } from "../../store"; const spin = keyframes` @@ -50,20 +52,25 @@ const LoadingText = styled.span` font-size: 14px; `; -const makeSelectAllChartsData = (allTables, muni) => { +const makeSelectAllChartsData = (allTables, muni, datatype) => { return createSelector( - [ - (state) => { - return state.chart.cache; - }, - ], + [(state) => { + switch (datatype) { + case 'subregion': + return state.subregion.cache; + case 'rpa': + return state.rparegion.cache; + default: + return state.chart.cache; + } + }], (cache) => { const result = allTables.reduce( (acc, table) => ({ ...acc, [table]: cache[table]?.[muni] || [], }), - {} // initial value as an empty object + {} ); return result; } @@ -83,11 +90,11 @@ const allTables = (() => { return Array.from(tables); })(); -export default function DownloadAllChartsButton({ muni }) { +export default function DownloadAllChartsButton({ muni, datatype }) { const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); const [loadingStatus, setLoadingStatus] = useState(""); - const selectAllChartsData = makeSelectAllChartsData(allTables, muni); + const selectAllChartsData = makeSelectAllChartsData(allTables, muni, datatype); const allData = useSelector(selectAllChartsData); const fetchMissingData = async () => { @@ -103,10 +110,26 @@ export default function DownloadAllChartsButton({ muni }) { if (needsFetch) { totalToFetch++; + let fetchPromise; + switch (datatype) { + case 'subregion': + fetchPromise = dispatch( + fetchSubregionChartData({ subregionId: muni, chartInfo }) + ); + break; + case 'rpa': + fetchPromise = dispatch( + fetchRPAregionChartData({ rpa_id: muni, chartInfo }) + ); + break; + default: + fetchPromise = dispatch( + fetchChartData({ chartInfo, municipality: muni }) + ); + } + fetchPromises.push( - dispatch( - fetchChartData({ chartInfo: chartInfo, municipality: muni }) - ).then(() => { + fetchPromise.then(() => { fetched++; setLoadingStatus(`Fetching data (${fetched}/${totalToFetch})`); }) @@ -135,7 +158,18 @@ export default function DownloadAllChartsButton({ muni }) { Object.values(charts).forEach((category) => { Object.values(category).forEach((chartInfo) => { Object.keys(chartInfo.tables).forEach((tableName) => { - excelData[tableName] = state.chart.cache[tableName]?.[muni] || []; + let data; + switch (datatype) { + case 'subregion': + data = state.subregion.cache[tableName]?.[muni] || []; + break; + case 'rpa': + data = state.rparegion.cache[tableName]?.[muni] || []; + break; + default: + data = state.chart.cache[tableName]?.[muni] || []; + } + excelData[tableName] = data; }); }); }); @@ -165,9 +199,14 @@ export default function DownloadAllChartsButton({ muni }) { Object.entries(data).forEach(([tableName, tableData]) => { if (tableData && tableData.length > 0) { - // Create worksheet with municipality header - const muniHeader = [['Municipality:', muni], []]; - const ws = XLSX.utils.aoa_to_sheet(muniHeader); + // Create worksheet with header based on data type + const header = [ + [datatype === 'subregion' ? 'Subregion:' : + datatype === 'rpa' ? 'RPA Region:' : + 'Municipality:', muni], + [] + ]; + const ws = XLSX.utils.aoa_to_sheet(header); // Add the data starting at row 2 XLSX.utils.sheet_add_json(ws, tableData, { origin: 'A2', skipHeader: false }); @@ -180,13 +219,14 @@ export default function DownloadAllChartsButton({ muni }) { }); setLoadingStatus("Downloading..."); - XLSX.writeFile(wb, `${muni}_all_charts_data.xlsx`); + const filename = `${muni}_${datatype}_charts_data.xlsx`; + XLSX.writeFile(wb, filename); }; return ( {isLoading && } @@ -199,4 +239,5 @@ export default function DownloadAllChartsButton({ muni }) { DownloadAllChartsButton.propTypes = { muni: PropTypes.string.isRequired, + datatype: PropTypes.oneOf(['municipality', 'subregion', 'rpa']).isRequired, }; diff --git a/src/components/field/DownloadChartButton.jsx b/src/components/field/DownloadChartButton.jsx index 208554e..4dc1ff8 100644 --- a/src/components/field/DownloadChartButton.jsx +++ b/src/components/field/DownloadChartButton.jsx @@ -38,7 +38,7 @@ const makeSelectChartData = (tables, muni) => createSelector( }), {}) ); -export default function DownloadChartButton({ chart, muni, isSubregion }) { +export default function DownloadChartButton({ chart, muni, isSubregion, isRPAregion }) { const selectChartData = React.useMemo( () => makeSelectChartData(Object.keys(chart.tables), muni), [chart.tables, muni] @@ -58,13 +58,30 @@ export default function DownloadChartButton({ chart, muni, isSubregion }) { } ); + const selectRPAregionCache = createSelector( + [(state) => state.rparegion.cache], + (cache) => { + if (isRPAregion) { + const tableName = Object.keys(chart.tables)[0]; + return cache[tableName]?.[muni] || []; + } + return []; + } + ); + const subregionCache = useSelector(selectSubregionCache); - + const rpaCache = useSelector(selectRPAregionCache); const downloadCsv = () => { try { const tableName = Object.keys(chartData)[0]; - const data = isSubregion ? subregionCache : chartData[tableName]; - + let data; + if (isRPAregion) { + data = rpaCache; + } else { + data = isSubregion ? subregionCache : chartData[tableName]; + } + + if (!data || data.length === 0) { console.error('No data available for the selected municipality.'); return; @@ -72,7 +89,14 @@ export default function DownloadChartButton({ chart, muni, isSubregion }) { // Convert data to CSV const headers = Object.keys(data[0]); - const firstRow = isSubregion ? ['Subregion:', SUBREGIONS[muni] ] : ['Municipality:', muni].join(','); + let firstRow; + if (isSubregion) { + firstRow = ['Subregion:', SUBREGIONS[muni]]; + } else if (isRPAregion) { + firstRow = ['RPAregion:', "MAPC"]; + } else { + firstRow = ['Municipality:', muni]; + } const csv = [ firstRow, headers.join(','), diff --git a/src/components/visualizations/ChartDetails.jsx b/src/components/visualizations/ChartDetails.jsx index 7a51cc3..4b802f2 100644 --- a/src/components/visualizations/ChartDetails.jsx +++ b/src/components/visualizations/ChartDetails.jsx @@ -45,7 +45,7 @@ const makeSelectChartData = (tables, muni) => createSelector( }), {}) ); -const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { +const ChartDetails = ({ chart, children, muni, onViewData, isSubregion, isRPAregion }) => { const [timeframe, setTimeframe] = useState(typeof chart.timeframe === 'string' ? chart.timeframe : 'Unknown'); const selectChartData = React.useMemo( @@ -69,7 +69,19 @@ const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { } ); + const selectRPAregionCache = createSelector( + [(state) => state.rparegion.cache], + (cache) => { + if (isRPAregion) { + const tableName = Object.keys(chart.tables)[0]; + return cache[tableName]?.[muni] || []; + } + return []; + } + ); + const subregionCache = useSelector(selectSubregionCache); + const rpaCache = useSelector(selectRPAregionCache); useEffect(() => { if (typeof chart.timeframe === 'function') { @@ -81,8 +93,9 @@ const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { if (isSubregion) { // Use the cached aggregated data from subregion state onViewData(subregionCache, chart.title); + } else if (isRPAregion) { + onViewData(rpaCache, chart.title); } else { - console.log("data= ", data); onViewData(data, chart.title); } }; @@ -105,6 +118,7 @@ const ChartDetails = ({ chart, children, muni, onViewData, isSubregion }) => { chart={chart} muni={muni} isSubregion={isSubregion} + isRPAregion={isRPAregion} /> @@ -159,7 +173,8 @@ ChartDetails.propTypes = { }; ChartDetails.defaultProps = { - isSubregion: false + isSubregion: false, + isRPAregion: false }; export default ChartDetails; diff --git a/src/components/visualizations/StackedBarChart.jsx b/src/components/visualizations/StackedBarChart.jsx index 5930076..c058830 100644 --- a/src/components/visualizations/StackedBarChart.jsx +++ b/src/components/visualizations/StackedBarChart.jsx @@ -395,6 +395,7 @@ StackedBarChart.propTypes = { width: PropTypes.number, height: PropTypes.number, isSubregion: PropTypes.bool, + isRPAregion: PropTypes.bool, }; export default StackedBarChart; \ No newline at end of file diff --git a/src/constants/charts.js b/src/constants/charts.js index bb41f68..2bb7e7f 100644 --- a/src/constants/charts.js +++ b/src/constants/charts.js @@ -142,7 +142,27 @@ export default { AND acs_year = ( SELECT MAX(acs_year) FROM tabular.b03002_race_ethnicity_acs_m) `; - return queryString; + return queryString; + }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + select + acs_year, + nhwhi, + nhaa, + nhna, + nhas, + nhpi, + nhoth, + nhmlt, + lat + from + tabular.b03002_race_ethnicity_acs_m + where muni_id = '${rpaId}' + AND acs_year = ( SELECT MAX(acs_year) + FROM tabular.b03002_race_ethnicity_acs_m) + `; + return queryString; }, }, pop_by_age: { @@ -210,28 +230,58 @@ export default { })); }, subregionDataQuery: (subregionId) => { - const queryString = `SELECT - years, - totpop, - pop_u18, - pop18_24, - pop25_34, - pop35_39, - pop40_44, - pop45_49, - pop50_54, - pop55_59, - pop60_61, - pop62_64, - pop65_66, - pop67_69, - pop70_74, - pop75_79, - pop80_84, - pop85o - FROM tabular.census2010_p12_pop_by_age_m - WHERE muni_id = '${subregionId}' - and years = (SELECT MAX(years) from tabular.census2010_p12_pop_by_age_m) + const queryString = ` + SELECT + years, + totpop, + pop_u18, + pop18_24, + pop25_34, + pop35_39, + pop40_44, + pop45_49, + pop50_54, + pop55_59, + pop60_61, + pop62_64, + pop65_66, + pop67_69, + pop70_74, + pop75_79, + pop80_84, + pop85o + FROM + tabular.census2010_p12_pop_by_age_m + WHERE + muni_id = '${subregionId}' + `; + return queryString; + }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + SELECT + years, + totpop, + pop_u18, + pop18_24, + pop25_34, + pop35_39, + pop40_44, + pop45_49, + pop50_54, + pop55_59, + pop60_61, + pop62_64, + pop65_66, + pop67_69, + pop70_74, + pop75_79, + pop80_84, + pop85o + FROM + tabular.census2010_p12_pop_by_age_m + WHERE + muni_id = '${rpaId}' `; return queryString; }, @@ -417,6 +467,20 @@ export default { `; return queryString; }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + SELECT + cal_year, + naicstitle, + naicscode, + avgemp + FROM tabular.econ_es202_naics_2d_m + WHERE muni_id = '${rpaId}' + AND naicstitle IS NOT NULL + ORDER BY cal_year, naicstitle; + `; + return queryString; + }, }, }, education: { @@ -523,6 +587,11 @@ export default { `; return queryString; }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + `; + return queryString; + }, }, edu_attainment_by_race: { type: "stacked-bar", @@ -643,53 +712,43 @@ export default { }, subregionDataQuery: (subregionId) => { const queryString = ` - SELECT - acs_year, - nhwlh, - nhwhs, - nhwsc, - nhwbd, - aalh, - aahs, - aasc, - aabd, - nalh, - nahs, - nasc, - nabd, - aslh, - ashs, - assc, - asbd, - pilh, - pihs, - pisc, - pibd, - othlh, - othhs, - othsc, - othbd, - mltlh, - mlths, - mltsc, - mltbd, - latlh, - laths, - latsc, - latbd - FROM tabular.c15002_educational_attainment_by_race_acs_m - WHERE muni_id = '${subregionId}' - AND acs_year IN ( - SELECT DISTINCT acs_year + SELECT + acs_year, + nhwlh, nhwhs, nhwsc, nhwbd, + aalh, aahs, aasc, aabd, + nalh, nahs, nasc, nabd, + aslh, ashs, assc, asbd, + pilh, pihs, pisc, pibd, + othlh, othhs, othsc, othbd, + mltlh, mlths, mltsc, mltbd, + latlh, laths, latsc, latbd + FROM tabular.c15002_educational_attainment_by_race_acs_m + WHERE muni_id = '${subregionId}' + AND acs_year = ( + SELECT MAX(acs_year) FROM tabular.c15002_educational_attainment_by_race_acs_m - WHERE muni_id IN ( - SELECT muni_id - FROM tabular._datakeys_muni_all - WHERE subrg_id = ${subregionId} - ) - ORDER BY acs_year DESC - ) - ORDER BY acs_year asc + ) + `; + return queryString; + }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + SELECT + acs_year, + nhwlh, nhwhs, nhwsc, nhwbd, + aalh, aahs, aasc, aabd, + nalh, nahs, nasc, nabd, + aslh, ashs, assc, asbd, + pilh, pihs, pisc, pibd, + othlh, othhs, othsc, othbd, + mltlh, mlths, mltsc, mltbd, + latlh, laths, latsc, latbd + FROM tabular.c15002_educational_attainment_by_race_acs_m + WHERE muni_id = '${rpaId}' + AND acs_year = ( + SELECT MAX(acs_year) + FROM tabular.c15002_educational_attainment_by_race_acs_m + ) `; return queryString; }, @@ -909,7 +968,7 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; const queryString2 = ` `; return [queryString1, queryString2]; - } + }, }, energy_usage_electricity: { type: "stacked-area", diff --git a/src/containers/visualizations/LineChart.js b/src/containers/visualizations/LineChart.js index ac228b4..0ed81be 100644 --- a/src/containers/visualizations/LineChart.js +++ b/src/containers/visualizations/LineChart.js @@ -1,6 +1,7 @@ import { connect } from 'react-redux'; import LineChart from '../../components/visualizations/LineChart'; + function valuesHaveData(transformedData) { if (!Array.isArray(transformedData) || transformedData.length === 0) return false; @@ -11,12 +12,42 @@ function valuesHaveData(transformedData) { ); } + const mapStateToProps = (state, props) => { - const { muni, chart, isSubregion } = props; + const { muni, chart, isSubregion, isRPAregion } = props; const tables = Object.keys(chart.tables); + // Handle RPA region data + if (isRPAregion) { + if (tables.every((table) => state.rparegion.cache[table] && state.rparegion.cache[table][muni])) { + const rparegionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.rparegion.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(rparegionTables, chart); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming RPA region data:', error); + return { + ...props, + xAxis: { label: '' }, + yAxis: { label: '' }, + data: [], + hasData: false, + }; + } + } + } // Handle subregion data - if (isSubregion) { + else if (isSubregion) { if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { const subregionTables = tables.reduce((acc, table) => ({ ...acc, @@ -64,10 +95,10 @@ const mapStateToProps = (state, props) => { console.error('Error transforming data:', error); return { ...props, - xAxis: { label: '' }, - yAxis: { label: '' }, - data: [], - hasData: false, + xAxis: { label: '' }, + yAxis: { label: '' }, + data: [], + hasData: false, }; } } @@ -84,4 +115,4 @@ const mapStateToProps = (state, props) => { const mapDispatchToProps = (dispatch, props) => ({}); export default connect(mapStateToProps, mapDispatchToProps)(LineChart); -export { valuesHaveData }; +export {valuesHaveData}; diff --git a/src/containers/visualizations/PieChart.js b/src/containers/visualizations/PieChart.js index 2887a8f..4a3863e 100644 --- a/src/containers/visualizations/PieChart.js +++ b/src/containers/visualizations/PieChart.js @@ -1,10 +1,11 @@ import { connect } from 'react-redux'; import PieChart from '../../components/visualizations/PieChart'; + function valuesHaveData(transformedData) { const checkData = transformedData.reduce((acc, row) => { let datumHasValue = false; - if (row.value !== null && row.value !== 0) { + if (row.y !== null && row.y !== 0) { datumHasValue = true; } acc.push(datumHasValue); @@ -18,11 +19,38 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart, isSubregion } = props; + const { muni, chart, isSubregion, isRPAregion } = props; const tables = Object.keys(chart.tables); + // Handle RPA region data + if (isRPAregion) { + if (tables.every((table) => state.rparegion.cache[table] && state.rparegion.cache[table][muni])) { + const rparegionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.rparegion.cache[table][muni] + }), {}); + + try { + const transformedData = chart.transformer(rparegionTables, chart); + return { + ...props, + xAxis: chart.xAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming RPA region data:', error); + return { + ...props, + xAxis: { format: (d) => d }, + data: [], + hasData: false, + }; + } + } + } // Handle subregion data - if (isSubregion) { + else if (isSubregion) { if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { const subregionTables = tables.reduce((acc, table) => ({ ...acc, diff --git a/src/containers/visualizations/StackedAreaChart.js b/src/containers/visualizations/StackedAreaChart.js index 9ae8dd7..6495d6f 100644 --- a/src/containers/visualizations/StackedAreaChart.js +++ b/src/containers/visualizations/StackedAreaChart.js @@ -17,12 +17,40 @@ function valuesHaveData(transformedData) { return false; } + const mapStateToProps = (state, props) => { - const { muni, chart, isSubregion } = props; + const { muni, chart, isSubregion, isRPAregion } = props; const tables = Object.keys(chart.tables); + + // Handle RPA region data + if (isRPAregion) { + if (tables.every((table) => state.rparegion.cache[table] && state.rparegion.cache[table][muni])) { + const rparegionTables = tables.reduce((acc, table) => ({ + ...acc, + [table]: state.rparegion.cache[table][muni] + }), {}); + try { + const transformedData = chart.transformer(rparegionTables, chart); + return { + ...props, + xAxis: chart.xAxis, + data: transformedData, + hasData: valuesHaveData(transformedData), + }; + } catch (error) { + console.error('Error transforming RPA region data:', error); + return { + ...props, + xAxis: { format: (d) => d }, + data: [], + hasData: false, + }; + } + } + } // Handle subregion data - if (isSubregion) { + else if (isSubregion) { if (tables.every((table) => state.subregion.cache[table] && state.subregion.cache[table][muni])) { const subregionTables = tables.reduce((acc, table) => ({ ...acc, @@ -31,11 +59,9 @@ const mapStateToProps = (state, props) => { try { const transformedData = chart.transformer(subregionTables, chart); - console.log(transformedData); return { ...props, xAxis: chart.xAxis, - yAxis: chart.yAxis, data: transformedData, hasData: valuesHaveData(transformedData), }; @@ -43,8 +69,7 @@ const mapStateToProps = (state, props) => { console.error('Error transforming subregion data:', error); return { ...props, - xAxis: { label: '' }, - yAxis: { label: '' }, + xAxis: { format: (d) => d }, data: [], hasData: false, }; @@ -63,7 +88,6 @@ const mapStateToProps = (state, props) => { return { ...props, xAxis: chart.xAxis, - yAxis: chart.yAxis, data: transformedData, hasData: valuesHaveData(transformedData), }; @@ -71,8 +95,7 @@ const mapStateToProps = (state, props) => { console.error('Error transforming data:', error); return { ...props, - xAxis: { label: '' }, - yAxis: { label: '' }, + xAxis: { format: (d) => d }, data: [], hasData: false, }; @@ -81,8 +104,7 @@ const mapStateToProps = (state, props) => { return { ...props, - xAxis: { label: '' }, - yAxis: { label: '' }, + xAxis: { format: (d) => d }, data: [], hasData: false, }; @@ -91,4 +113,3 @@ const mapStateToProps = (state, props) => { const mapDispatchToProps = (dispatch, props) => ({}); export default connect(mapStateToProps, mapDispatchToProps)(StackedAreaChart); -export { valuesHaveData }; diff --git a/src/containers/visualizations/StackedBarChart.js b/src/containers/visualizations/StackedBarChart.js index 5bdab7e..151c9d5 100644 --- a/src/containers/visualizations/StackedBarChart.js +++ b/src/containers/visualizations/StackedBarChart.js @@ -18,7 +18,7 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart, isSubregion } = props; + const { muni, chart, isSubregion, isRPAregion } = props; const tables = Object.keys(chart.tables); // Handle subregion data @@ -33,9 +33,18 @@ const mapStateToProps = (state, props) => { hasData: valuesHaveData(chart.transformer(subregionTables, chart)), }; } - } - // Handle regular municipality data - else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { + } else if (isRPAregion) { + if (tables.every((table) => state.rparegion.cache[table] && state.rparegion.cache[table][muni])) { + const rpaTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.rparegion.cache[table][muni] }), {}); + return { + ...props, + xAxis: chart.xAxis, + yAxis: chart.yAxis, + data: chart.transformer(rpaTables, chart), + hasData: valuesHaveData(chart.transformer(rpaTables, chart)), + }; + } + } else if (tables.every((table) => state.chart.cache[table] && state.chart.cache[table][muni])) { const muniTables = tables.reduce((acc, table) => Object.assign(acc, { [table]: state.chart.cache[table][muni] }), {}); return { ...props, diff --git a/src/main.jsx b/src/main.jsx index c127de1..fffa3bb 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -13,6 +13,7 @@ import store from "./store"; import "../src/assets/styles/app.scss"; import CommunityProfilesPage from "./pages/CommunityProfilesPage"; import SubregionProfilesPage from "./pages/SubregionProfilesPage"; +import RPAregionProfilesPage from "./pages/RPAregionProfilesPage"; import tabs from "./constants/tabs"; import municipalities from "./assets/data/ma-munis.json"; @@ -22,7 +23,7 @@ const muniOptions = municipalities.features.map( ); const tabOptions = tabs.map(tab => tab.value); - +// todo: get this from the api ? const VALID_SUBREGIONS = ['355', '356', '357', '358', '359', '360', '361', '362']; const ProfileRoute = ({ muniOptions, tabOptions }) => { @@ -57,6 +58,21 @@ const SubregionProfileRoute = ({ tabOptions }) => { return ; }; +const RPAProfileRoute = ({ tabOptions }) => { + const { rpaId, tab } = useParams(); + + if (!tab || !tabOptions.includes(tab)) { + return ; + } + + if(!rpaId) { + return
RPA not found
; + } + + return ; +}; + + const router = createBrowserRouter([ { path: "/", @@ -99,6 +115,10 @@ const router = createBrowserRouter([ path: "/profile/subregion/:subregionId/:tab?", element: }, + { + path: "/profile/rpa/:rpaId/:tab?", + element: + }, { path:"gallery", children:[ diff --git a/src/pages/RPAregionProfilesPage.jsx b/src/pages/RPAregionProfilesPage.jsx new file mode 100644 index 0000000..779e1ad --- /dev/null +++ b/src/pages/RPAregionProfilesPage.jsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import RPAregionProfilesView from '../components/RPAregionProfilesView'; +import { fetchRPAregionData, selectRPAregionData, selectRPAregionLoading, selectRPAregionError } from '../reducers/rparegionSlice'; + +const RPAregionProfilesPage = () => { + const dispatch = useDispatch(); + + // Get all RPA region data from Redux store + const rparegionData = useSelector(selectRPAregionData); + const loading = useSelector(selectRPAregionLoading); + const error = useSelector(selectRPAregionError); + + // Effect for fetching RPA region data + useEffect(() => { + if (!Object.keys(rparegionData).length) { + dispatch(fetchRPAregionData()); + } + }, [dispatch, rparegionData]); + + if (loading) { + return
Loading RPA region data...
; + } + + if (error) { + return
Error loading RPA region data: {error}
; + } + + return ; +}; + +export default RPAregionProfilesPage; \ No newline at end of file diff --git a/src/pages/SubregionProfilesPage.jsx b/src/pages/SubregionProfilesPage.jsx index 1e0b1af..632dd23 100644 --- a/src/pages/SubregionProfilesPage.jsx +++ b/src/pages/SubregionProfilesPage.jsx @@ -1,21 +1,34 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import SubregionProfilesView from '../components/SubregionProfilesView'; +import { fetchSubregionData } from '../reducers/subregionSlice'; const SubregionProfilesPage = () => { + const dispatch = useDispatch(); const { subregionId } = useParams(); - // Get subregion data from Redux store - const subregionData = useSelector(state => state.subregion.data[subregionId]); - - if (!subregionData) { - return
Subregion not found
; + // Get all subregion data from Redux store + const subregionData = useSelector(state => state.subregion.data); + const loading = useSelector(state => state.subregion.loading); + const error = useSelector(state => state.subregion.error); + + // Effect for fetching subregion data + useEffect(() => { + if (!Object.keys(subregionData).length) { + dispatch(fetchSubregionData()); + } + }, [dispatch, subregionData]); + + if (loading) { + return
Loading subregion data...
; + } + + if (error) { + return
Error loading subregion data: {error}
; } - return ( - - ); + return ; }; export default SubregionProfilesPage; \ No newline at end of file diff --git a/src/reducers/rparegionSlice.js b/src/reducers/rparegionSlice.js new file mode 100644 index 0000000..e1c5dfc --- /dev/null +++ b/src/reducers/rparegionSlice.js @@ -0,0 +1,187 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import locations from '../constants/locations'; + +export const fetchRPAregionChartData = createAsyncThunk( + "rparegion/fetchChartData", + async ({ rpa_id , chartInfo }, { dispatch, getState }) => { + console.log('fetching rpa region chart data', rpa_id, chartInfo); + const { rparegion } = getState(); + const tableNames = Object.keys(chartInfo.tables); + + const dispatchUpdate = (data, tableName) => { + dispatch( + updateRPAregionChart({ + table: tableName, + muni: rpa_id, + data, + }) + ); + }; + + // Get all queries first + const queries = chartInfo.rparegionDataQuery(rpa_id); + + // Handle multiple tables - match each table with its corresponding query + if (tableNames.length > 1) { + // Make sure we have the same number of queries as tables + if (queries.length !== tableNames.length) { + console.error("Mismatch between number of tables and queries"); + return; + } + + // Process each table with its corresponding query + for (let i = 0; i < tableNames.length; i++) { + const tableName = tableNames[i]; + const query = queries[i]; + let { years } = chartInfo.tables[tableName]; + + if (rparegion.cache[tableName]?.[rpa_id]) { + continue; + } + + if (typeof years === "function") { + try { + const yearsResult = await years(); + years = yearsResult; + } catch (error) { + console.error("Error executing years function:", error); + continue; + } + } + + try { + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=${query}`; + const response = await fetch(api); + const payload = (await response.json()) || {}; + dispatchUpdate(payload.rows, tableName); + } catch (error) { + console.error(`Error fetching data for table ${tableName}:`, error); + } + } + return; + } + + // Handle single table + const tableName = tableNames[0]; + let { years } = chartInfo.tables[tableName]; + + if (typeof years === "function") { + try { + const yearsResult = await years(); + years = yearsResult; + } catch (error) { + console.error("Error executing years function:", error); + return; + } + } + + try { + // For single table, use the first (and should be only) query + const query = Array.isArray(queries) ? queries[0] : queries; + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=${query}`; + const response = await fetch(api); + const payload = (await response.json()) || {}; + dispatchUpdate(payload.rows, tableName); + return { table: tableName, muni: rpa_id, data: payload.rows }; + } catch (error) { + console.error(`Error fetching data for table ${tableName}:`, error); + throw error; + } + } +); + +export const fetchRPAregionData = createAsyncThunk( + "rparegion/fetchData", + async () => { + const api = `${locations.BROWSER_API}?token=${locations.DS_TOKEN}&query=`; + const query = ` + SELECT + muni_id, + muni_name, + region as rpa_name, + region_id as rpa_id + FROM tabular._datakeys_muni_all + WHERE rpa_name IS NOT NULL + ORDER BY region, muni_name + `; + + const response = await fetch(`${api}${encodeURIComponent(query)}`); + const payload = await response.json(); + + // Transform the data into the desired structure + const rparegionMap = {}; + + payload.rows?.forEach((row) => { + const { muni_id, muni_name, rpa_name, rpa_id} = row; + + if (!rparegionMap[rpa_id]) { + rparegionMap[rpa_id] = { + rpa_name, + municipalities: [], + totalMunis: 0, + }; + } + + // Add municipality data + rparegionMap[rpa_id].municipalities.push({ + muni_name, + muni_id, + }); + + rparegionMap[rpa_id].totalMunis = + rparegionMap[rpa_id].municipalities.length; + }); + + return rparegionMap; + } +); + +const rparegionSlice = createSlice({ + name: "rparegion", + initialState: { + data: {}, + loading: false, + error: null, + cache: {} + }, + reducers: { + updateRPAregionChart: (state, action) => { + const { table: tableName, muni: rparegionId, data } = action.payload; + + if (!tableName || !rparegionId || !data) { + return; + } + + if (!state.cache[tableName]) { + state.cache[tableName] = {}; + } + + state.cache[tableName][rparegionId] = data; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchRPAregionData.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchRPAregionData.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + }) + .addCase(fetchRPAregionData.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message; + }) + .addCase(fetchRPAregionChartData.fulfilled, (state, action) => { + // No need to handle this case since we're using updateRPAregionChart reducer + }); + }, +}); + +export const { updateRPAregionChart } = rparegionSlice.actions; +export const selectRPAregionData = (state) => state.rparegion.data; +export const selectRPAregionLoading = (state) => state.rparegion.loading; +export const selectRPAregionError = (state) => state.rparegion.error; + +export default rparegionSlice.reducer; \ No newline at end of file diff --git a/src/store.js b/src/store.js index 92ceee8..7743460 100644 --- a/src/store.js +++ b/src/store.js @@ -4,6 +4,7 @@ import searchReducer from './reducers/searchSlice'; import municipalityReducer from './reducers/municipalitySlice'; import chartReducer from './reducers/chartSlice'; import subregionReducer from './reducers/subregionSlice'; +import rparegionReducer from './reducers/rparegionSlice'; export const store = configureStore({ reducer: { @@ -12,6 +13,7 @@ export const store = configureStore({ municipality: municipalityReducer, chart: chartReducer, subregion: subregionReducer, + rparegion: rparegionReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ From d678c3be0626fbd66d363ef8fbce62337e01e261 Mon Sep 17 00:00:00 2001 From: ztocode Date: Wed, 16 Apr 2025 11:22:01 -0400 Subject: [PATCH 5/6] fix charts for different data types --- src/components/CommunityProfilesView.jsx | 2 +- src/components/RPAregionProfilesView.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CommunityProfilesView.jsx b/src/components/CommunityProfilesView.jsx index bce678f..603df32 100644 --- a/src/components/CommunityProfilesView.jsx +++ b/src/components/CommunityProfilesView.jsx @@ -82,7 +82,7 @@ const CommunityProfilesView = ({ name, municipalFeature, muniSlug }) => { > Print charts - + diff --git a/src/components/RPAregionProfilesView.jsx b/src/components/RPAregionProfilesView.jsx index 2ac7fac..d8a52de 100644 --- a/src/components/RPAregionProfilesView.jsx +++ b/src/components/RPAregionProfilesView.jsx @@ -208,7 +208,7 @@ const RPAregionProfilesView = () => { className={tabItem.value === activeTab ? "active" : ""} > setActiveTab(tabItem.value)} > {tabItem.label} From e33795817cbe3d936889bc4cb78a7b1f17391cce Mon Sep 17 00:00:00 2001 From: ztocode Date: Wed, 16 Apr 2025 11:53:15 -0400 Subject: [PATCH 6/6] Fix x-axis label order to display years in ascending order --- src/components/visualizations/StackedBarChart.jsx | 15 ++++++++++++--- src/constants/charts.js | 6 +++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/visualizations/StackedBarChart.jsx b/src/components/visualizations/StackedBarChart.jsx index c058830..f10f034 100644 --- a/src/components/visualizations/StackedBarChart.jsx +++ b/src/components/visualizations/StackedBarChart.jsx @@ -176,7 +176,16 @@ const StackedBarChart = (props) => { .range([0, width]) : d3 .scaleBand() - .domain(data.map(d => d.x)) + .domain(data.map(d => d.x).sort((a, b) => { + // If both values can be parsed as numbers (e.g. years), sort numerically + const numA = parseInt(a); + const numB = parseInt(b); + if (!isNaN(numA) && !isNaN(numB)) { + return numA - numB; + } + // Otherwise use the provided sort function or default string comparison + return props.xAxis.sort ? props.xAxis.sort(a, b) : (a > b ? 1 : -1); + })) .range([0, width]) .paddingInner(0.2); @@ -263,12 +272,12 @@ const StackedBarChart = (props) => { // Add axes with proper formatting const xAxis = props.horizontal - ? d3.axisBottom(xScale).tickFormat(props.xAxis.format || (d => d < 1 ? d3.format('.0%')(d) : d)) + ? d3.axisBottom(xScale).tickFormat(props.xAxis.format || (d => d <= 1 ? d3.format('.0%')(d) : d)) : d3.axisBottom(xScale).tickFormat(props.xAxis.format); const yAxis = props.horizontal ? d3.axisLeft(yScale) - : d3.axisLeft(yScale).tickFormat(props.yAxis.format || (d => d < 1 ? d3.format('.0%')(d) : d)); + : d3.axisLeft(yScale).tickFormat(props.yAxis.format || (d => d <= 1 ? d3.format('.0%')(d) : d)); // Add axes const xAxisG = g diff --git a/src/constants/charts.js b/src/constants/charts.js index 2bb7e7f..f236e35 100644 --- a/src/constants/charts.js +++ b/src/constants/charts.js @@ -1146,7 +1146,11 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; units_permitted: { type: "stacked-area", title: "Housing Units Permitted", - xAxis: { label: "Year" }, + xAxis: { + label: "Year", + format: format.string.default, + sort: (a, b) => parseInt(a) - parseInt(b) + }, yAxis: { label: "Units Permitted" }, tables: { "tabular.hous_building_permits_m": {