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/CommunitySelectorView.jsx b/src/components/CommunitySelectorView.jsx index 0577d9a..b95b8e8 100644 --- a/src/components/CommunitySelectorView.jsx +++ b/src/components/CommunitySelectorView.jsx @@ -1,21 +1,142 @@ -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'; +import { fetchRPAregionData, selectRPAregionData, selectRPAregionLoading } from '../reducers/rparegionSlice'; +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 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); + } + }; + 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 +145,11 @@ const CommunitySelectorView = ({ muniLines, muniFill, municipalityPoly, toProfil
); diff --git a/src/components/RPAregionProfilesView.jsx b/src/components/RPAregionProfilesView.jsx new file mode 100644 index 0000000..d8a52de --- /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 new file mode 100644 index 0000000..60a2c82 --- /dev/null +++ b/src/components/SubregionProfilesView.jsx @@ -0,0 +1,518 @@ +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 { fetchSubregionChartData, fetchSubregionData, selectSubregionData } 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'; + +// 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]', + 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 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(); + const { subregionId, tab } = useParams(); + const [activeTab, setActiveTab] = useState(tab || 'demographics'); + const [modalConfig, setModalConfig] = useState({ + show: false, + data: null, + title: '' + }); + + const subregionData = useSelector(selectSubregionData); + const municipalities = subregionData[subregionId]?.municipalities || []; + + useEffect(() => { + setActiveTab(tab); + }, [tab]); + + // 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({ + show: true, + data: data, + title: `${title} (Aggregated)` + }); + } + + const handleCloseModal = () => { + setModalConfig({ + show: false, + data: null, + title: '' + }); + }; + + return ( +
+
+
+ {"< Back"} +
+
+
+

{SUBREGIONS[subregionId]}

+
+
+
+

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

+ + {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(SubregionProfilesView); \ No newline at end of file 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 76b2c39..4dc1ff8 100644 --- a/src/components/field/DownloadChartButton.jsx +++ b/src/components/field/DownloadChartButton.jsx @@ -19,6 +19,16 @@ const StyledButton = styled.button` background: #5DB37A; } `; +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 makeSelectChartData = (tables, muni) => 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, isRPAregion }) { const selectChartData = React.useMemo( () => makeSelectChartData(Object.keys(chart.tables), muni), [chart.tables, muni] @@ -36,11 +46,42 @@ 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 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 = 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; @@ -48,8 +89,16 @@ export default function DownloadChartButton({ chart, muni }) { // Convert data to CSV const headers = Object.keys(data[0]); + let firstRow; + if (isSubregion) { + firstRow = ['Subregion:', SUBREGIONS[muni]]; + } else if (isRPAregion) { + firstRow = ['RPAregion:', "MAPC"]; + } else { + firstRow = ['Municipality:', muni]; + } const csv = [ - ['Municipality:', muni].join(','), + firstRow, headers.join(','), ...data.map(row => headers.map(header => { @@ -65,7 +114,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 2dee7ea..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 }) => { +const ChartDetails = ({ chart, children, muni, onViewData, isSubregion, isRPAregion }) => { const [timeframe, setTimeframe] = useState(typeof chart.timeframe === 'string' ? chart.timeframe : 'Unknown'); const selectChartData = React.useMemo( @@ -57,6 +57,32 @@ 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 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') { chart.timeframe().then(setTimeframe); @@ -64,7 +90,14 @@ 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 if (isRPAregion) { + onViewData(rpaCache, chart.title); + } else { + onViewData(data, chart.title); + } }; return ( @@ -72,15 +105,21 @@ const ChartDetails = ({ chart, children, muni, onViewData }) => { {chart.title || 'Chart Title'} + {isSubregion && ' (Aggregated)'} View Data - + {children} @@ -130,6 +169,12 @@ ChartDetails.propTypes = { }).isRequired, muni: PropTypes.string.isRequired, onViewData: PropTypes.func.isRequired, + isSubregion: PropTypes.bool, +}; + +ChartDetails.defaultProps = { + isSubregion: false, + isRPAregion: false }; export default ChartDetails; 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 3f01074..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 @@ -394,6 +403,8 @@ StackedBarChart.propTypes = { wrapLeftLabel: PropTypes.bool, 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 79a0e3a..f236e35 100644 --- a/src/constants/charts.js +++ b/src/constants/charts.js @@ -124,6 +124,46 @@ 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; + }, + 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: { type: "stacked-bar", @@ -189,6 +229,62 @@ 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}' + `; + 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; + }, }, }, economy: { @@ -247,6 +343,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 +453,34 @@ 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; + }, + 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: { @@ -434,6 +582,16 @@ export default { ); return data; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + `; + return queryString; + }, + rparegionDataQuery: (rpaId) => { + const queryString = ` + `; + return queryString; + }, }, edu_attainment_by_race: { type: "stacked-bar", @@ -552,6 +710,48 @@ 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 = ( + SELECT MAX(acs_year) + FROM tabular.c15002_educational_attainment_by_race_acs_m + ) + `; + 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; + }, }, }, governance: { @@ -617,6 +817,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 +901,10 @@ export default { }, ]; }, + subregionDataQuery: (subregionId) => { + const queryString = ``; + return queryString; + }, }, energy_usage_gas: { type: "stacked-area", @@ -731,8 +959,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 +1025,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,11 +1119,38 @@ 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", 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": { @@ -950,6 +1220,10 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; [] ); }, + subregionDataQuery: (subregionId) => { + const queryString = ``; + return queryString; + }, }, }, "public-health": { @@ -1047,6 +1321,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 +1429,11 @@ SELECT CONCAT(MIN(cal_year), '-', MAX(cal_year)) AS latest_year FROM years;`; ); return []; }, + subregionDataQuery: (subregionId) => { + const queryString = ` + `; + return queryString; + }, }, }, transportation: { @@ -1207,6 +1491,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 +1547,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..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,71 @@ function valuesHaveData(transformedData) { ); } + const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion, isRPAregion } = 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 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 + else 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 +84,6 @@ const mapStateToProps = (state, props) => { try { const transformedData = chart.transformer(muniTables, chart); - return { ...props, xAxis: chart.xAxis, @@ -36,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, }; } } @@ -56,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 fccfb4f..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,24 +19,87 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion, isRPAregion } = 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 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 + else 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..6495d6f 100644 --- a/src/containers/visualizations/StackedAreaChart.js +++ b/src/containers/visualizations/StackedAreaChart.js @@ -17,27 +17,94 @@ function valuesHaveData(transformedData) { return false; } + const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion, isRPAregion } = 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 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 + else 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, + 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 { ...props, - xAxis: { - label: '', - }, - yAxis: { - label: '', - }, + xAxis: { format: (d) => d }, data: [], hasData: false, }; @@ -46,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 536c9d2..151c9d5 100644 --- a/src/containers/visualizations/StackedBarChart.js +++ b/src/containers/visualizations/StackedBarChart.js @@ -18,9 +18,33 @@ function valuesHaveData(transformedData) { } const mapStateToProps = (state, props) => { - const { muni, chart } = props; + const { muni, chart, isSubregion, isRPAregion } = props; const tables = Object.keys(chart.tables); - if (tables.every((table) => state.chart.cache[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) => 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)), + }; + } + } 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, @@ -30,6 +54,7 @@ const mapStateToProps = (state, props) => { hasData: valuesHaveData(chart.transformer(muniTables, chart)), }; } + return { ...props, xAxis: { @@ -46,4 +71,4 @@ const mapStateToProps = (state, props) => { 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 a4d8c83..fffa3bb 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,6 +12,8 @@ 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 RPAregionProfilesPage from "./pages/RPAregionProfilesPage"; import tabs from "./constants/tabs"; import municipalities from "./assets/data/ma-munis.json"; @@ -21,6 +23,8 @@ 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 }) => { const { muni, tab } = useParams(); @@ -36,6 +40,39 @@ const ProfileRoute = ({ muniOptions, tabOptions }) => { return ; }; +const SubregionProfileRoute = ({ tabOptions }) => { + const { subregionId, tab } = useParams(); + + if (!VALID_SUBREGIONS.includes(subregionId)) { + return ; + } + + if (!tab || !tabOptions.includes(tab)) { + return ; + } + + if (!subregionId || !VALID_SUBREGIONS.includes(subregionId)) { + return
Subregion not found
; + } + + 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: "/", @@ -73,7 +110,16 @@ const router = createBrowserRouter([ { path: "/profile/:muni/:tab?", element: - },{ + }, + { + path: "/profile/subregion/:subregionId/:tab?", + element: + }, + { + path: "/profile/rpa/:rpaId/: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/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 new file mode 100644 index 0000000..632dd23 --- /dev/null +++ b/src/pages/SubregionProfilesPage.jsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +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 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 ; +}; + +export default SubregionProfilesPage; \ No newline at end of file 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/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/reducers/subregionSlice.js b/src/reducers/subregionSlice.js new file mode 100644 index 0000000..58ac77b --- /dev/null +++ b/src/reducers/subregionSlice.js @@ -0,0 +1,179 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import locations from "../constants/locations"; + +//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 dispatchUpdate = (data, tableName) => { + dispatch( + updateSubregionChart({ + table: tableName, + muni: subregionId, + data, + }) + ); + }; + + // 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; + } + + // 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; + } + + 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", + 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; + } +); + +const subregionSlice = createSlice({ + name: "subregion", + initialState: { + data: {}, + loading: false, + error: null, + cache: {} + }, + reducers: { + updateSubregionChart: (state, action) => { + const { table: tableName, muni: subregionId, data } = action.payload; // Fix destructuring + + if (!tableName || !subregionId || !data) { + return; + } + + if (!state.cache[tableName]) { + state.cache[tableName] = {}; + } + + state.cache[tableName][subregionId] = 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; + }); + }, +}); + +export const selectSubregionData = (state) => state.subregion.data; +export const selectSubregionLoading = (state) => state.subregion.loading; +export const { updateSubregionChart } = subregionSlice.actions; +export default subregionSlice.reducer; diff --git a/src/store.js b/src/store.js index f567f52..7743460 100644 --- a/src/store.js +++ b/src/store.js @@ -3,6 +3,8 @@ 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'; +import rparegionReducer from './reducers/rparegionSlice'; export const store = configureStore({ reducer: { @@ -10,6 +12,8 @@ export const store = configureStore({ search: searchReducer, municipality: municipalityReducer, chart: chartReducer, + subregion: subregionReducer, + rparegion: rparegionReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({