From 47e2e07405232409e0b233693c1c751ccff87a92 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 1 Jun 2026 09:34:59 -0400 Subject: [PATCH 1/8] added poverty rate trend chart --- .../src/components/Charts/TrendCharts.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index db46f86..835f2f6 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -322,6 +322,97 @@ export const HousingTrendChart = ({ ); }; + +// --------------------------------------------------------------------------- +// Economics: Poverty Rate +// --------------------------------------------------------------------------- + +export const PovertyTrendChart = ({ + chart, +}: { + chart: ChartItem; +}) => { + const data = chart.data as any[]; + const compareData = (chart.compareData ?? []) as any[]; + if (!data || data.length === 0) return null; + + const years = Array.from(new Set(data.map((r) => r.year))).sort(); + const labels = chart.chartParams?.legendLabels as + | [string, string] + | undefined; + const cmpName = labels?.[1] ?? 'Comparison'; + + const buildPoint = (rows: any[], year: number) => { + const find = (label: string) => + rows.find((r) => r.year === year && r.Variable === label)?.Percent ?? + null; + return { + 'Poverty Rate': find('Poverty Rate'), + }; + }; + + const plotData = years.map((year) => ({ + year, + ...buildPoint(data, year), + ...(compareData.length > 0 + ? { + 'Poverty Rate (cmp)': buildPoint(compareData, year)['Poverty Rate'], + } + : {}), + })); + + return ( + <> + {compareData.length > 0 && } + + + + + + (val != null ? `${val}%` : '—')} /> + + + {compareData.length > 0 && ( + <> + + + )} + + + + ); +}; + + + + + + + + + + + + + // --------------------------------------------------------------------------- // Generic two-location trend chart for the DP-combined explorer // data: [{year, Value}] for side A From e3422d290127eaef2e5a722e4e9420a398ecacf7 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 1 Jun 2026 10:20:10 -0400 Subject: [PATCH 2/8] change to unemployment rate plot. Fixed if default location is state of VT --- backend/api/metadata_registry.py | 10 + .../api/routes/post_routes/post_acs5_db.py | 234 ++++++++++++++---- backend/api/routes/post_routes/post_qcew.py | 41 ++- .../src/components/Charts/TrendCharts.tsx | 25 +- .../components/Charts/configs/ChartDefs.tsx | 16 ++ 5 files changed, 243 insertions(+), 83 deletions(-) diff --git a/backend/api/metadata_registry.py b/backend/api/metadata_registry.py index 6fe49f3..bde78dd 100644 --- a/backend/api/metadata_registry.py +++ b/backend/api/metadata_registry.py @@ -61,6 +61,16 @@ "Income values are in nominal dollars (not inflation-adjusted across years)." ], }, + "unemployment_rate": { + "source": ( + "U.S. Census Bureau, American Community Survey 5-Year Estimates " + "(Table B23025)" + ), + "lastUpdated": "2023", + "caveats": [ + "Estimates for small geographies may have high margins of error." + ], + }, "qcew_employment": { "source": ( "U.S. Bureau of Labor Statistics, " diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 3a4bf1d..f980ee2 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -1,5 +1,6 @@ import logging +import pandas as pd from fastapi import APIRouter from api.metadata_registry import get_metadata @@ -11,83 +12,212 @@ router = APIRouter() +def _aggregate_to_state(df: pd.DataFrame) -> pd.DataFrame: + """Aggregate county-level data to state level by summing Value and averaging Percent.""" + if df.empty: + return df + # Sum the Value column (population counts) and average the Percent column + agg_dict = {} + for col in df.columns: + if col in ['year', 'Section', 'Variable']: + agg_dict[col] = 'first' + elif col == 'Value': + agg_dict[col] = 'sum' + elif col == 'Percent': + agg_dict[col] = 'mean' + + result = df.groupby(['year', 'Section', 'Variable'], + as_index=False).agg(agg_dict) + # Round percent to 1 decimal + if 'Percent' in result.columns: + result['Percent'] = result['Percent'].round(1) + return result + + +# Demographics @router.post("/load/acs5-db/tidy/demographics") async def tidy_demographics(request: FilterRequest): - rows = DB.execute( - """ - SELECT year, Section, Variable, Value, Percent - FROM b10_census - WHERE NAME = ? - AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Section, Variable - """, - [request.name, request.year_min, request.year_max], - ).df() + # If requesting Vermont (state-level), aggregate all counties + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b10_census + WHERE NAME LIKE '%County, Vermont' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Section, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b10_census + WHERE NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Section, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() return make_response(data=rows, metadata=get_metadata("demographics")) +# Education @router.post("/load/acs5-db/tidy/education") async def tidy_education(request: FilterRequest): - rows = DB.execute( - """ - SELECT year, Section, Variable, Value, Percent - FROM b15003_education - WHERE NAME = ? - AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Variable - """, - [request.name, request.year_min, request.year_max], - ).df() + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b15003_education + WHERE NAME LIKE '%County, Vermont' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b15003_education + WHERE NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() return make_response(data=rows, metadata=get_metadata("education")) +# Housing @router.post("/load/acs5-db/tidy/housing") async def tidy_housing(request: FilterRequest): - rows = DB.execute( - """ - SELECT year, Section, Variable, Value, Percent - FROM b_housing - WHERE NAME = ? - AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Variable - """, - [request.name, request.year_min, request.year_max], - ).df() + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_housing + WHERE NAME LIKE '%County, Vermont' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_housing + WHERE NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() return make_response(data=rows, metadata=get_metadata("housing")) +# Labor Force @router.post("/load/acs5-db/tidy/labor-force") async def tidy_labor_force(request: FilterRequest): - rows = DB.execute( - """ - SELECT year, Section, Variable, Value, Percent - FROM b_economic - WHERE NAME = ? - AND Section = 'Labor Force' - AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Variable - """, - [request.name, request.year_min, request.year_max], - ).df() + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_economic + WHERE NAME LIKE '%County, Vermont' + AND Section = 'Labor Force' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_economic + WHERE NAME = ? + AND Section = 'Labor Force' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() return make_response(data=rows, metadata=get_metadata("labor_force")) +# Income @router.post("/load/acs5-db/tidy/income") async def tidy_income(request: FilterRequest): - rows = DB.execute( - """ - SELECT year, Section, Variable, Value, Percent - FROM b_economic - WHERE NAME = ? - AND Section = 'Income' - AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Variable - """, - [request.name, request.year_min, request.year_max], - ).df() + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_economic + WHERE NAME LIKE '%County, Vermont' + AND Section = 'Income' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b_economic + WHERE NAME = ? + AND Section = 'Income' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() return make_response(data=rows, metadata=get_metadata("income")) +# Unemployment Rate +@router.post("/load/acs5-db/tidy/unemployment-rate") +async def tidy_unemployment_rate(request: FilterRequest): + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, NAME, Unemployment_Rate + FROM unemployment_rate + WHERE NAME LIKE '%County, Vermont' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Unemployment_Rate + """, + [request.year_min, request.year_max], + ).df() + # Average unemployment rate across counties for state level + if not rows.empty: + rows = rows.groupby(['year'], as_index=False).agg({ + 'Unemployment_Rate': 'mean', + 'NAME': 'first' + }) + else: + rows = DB.execute( + """ + SELECT year, NAME, Unemployment_Rate + FROM unemployment_rate + WHERE NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Unemployment_Rate + """, + [request.name, request.year_min, request.year_max], + ).df() + return make_response(data=rows, metadata=get_metadata("unemployment_rate")) + + # --------------------------------------------------------------------------- # DP-series combined explorer (DP02 / DP03 / DP04 / DP05) # --------------------------------------------------------------------------- diff --git a/backend/api/routes/post_routes/post_qcew.py b/backend/api/routes/post_routes/post_qcew.py index 48fd704..94de3be 100644 --- a/backend/api/routes/post_routes/post_qcew.py +++ b/backend/api/routes/post_routes/post_qcew.py @@ -22,25 +22,40 @@ @router.post("/load/qcew/employment") async def employment_by_sector(request: FilterRequest): - county = (request.filters or {}).get("County", [None])[0] + county = (request.filters or {}).get("County", [None])[ + 0] if request.filters else None - query = """ - SELECT year, quarter, quarter_label, sector, employment_4qma - FROM qcew - WHERE sector != 'Total' - {county_filter} - ORDER BY year, quarter, sector - """ - if county: - rows: pd.DataFrame = DB.execute( - query.format(county_filter="AND County = ?"), [county] - ).df() + # For state-level (no county specified), aggregate all counties + if not county and (not request.name or request.name.lower() == "vermont"): + query = """ + SELECT year, quarter, quarter_label, sector, employment_4qma + FROM qcew + WHERE sector != 'Total' + ORDER BY year, quarter, sector + """ + rows: pd.DataFrame = DB.execute(query).df() + elif county: + query = """ + SELECT year, quarter, quarter_label, sector, employment_4qma + FROM qcew + WHERE sector != 'Total' + AND County = ? + ORDER BY year, quarter, sector + """ + rows: pd.DataFrame = DB.execute(query, [county]).df() else: - rows = DB.execute(query.format(county_filter="")).df() + # No county and not Vermont state-level - return empty + return make_response(data=[], metadata=get_metadata("qcew_employment")) if rows.empty: return make_response(data=[], metadata=get_metadata("qcew_employment")) + # For state-level, aggregate by summing employment across counties + if not county and (not request.name or request.name.lower() == "vermont"): + rows = rows.groupby(['year', 'quarter', 'quarter_label', 'sector'], as_index=False).agg({ + 'employment_4qma': 'sum' + }) + # Pivot to wide format: one row per quarter_label, one column per sector wide = rows.pivot_table( index=["year", "quarter", "quarter_label"], diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index 835f2f6..adf0c02 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -324,10 +324,10 @@ export const HousingTrendChart = ({ // --------------------------------------------------------------------------- -// Economics: Poverty Rate +// Economics: Unemployment Rate // --------------------------------------------------------------------------- -export const PovertyTrendChart = ({ +export const UnemploymentTrendChart = ({ chart, }: { chart: ChartItem; @@ -347,7 +347,7 @@ export const PovertyTrendChart = ({ rows.find((r) => r.year === year && r.Variable === label)?.Percent ?? null; return { - 'Poverty Rate': find('Poverty Rate'), + 'Unemployment Rate': find('Unemployment Rate'), }; }; @@ -356,7 +356,7 @@ export const PovertyTrendChart = ({ ...buildPoint(data, year), ...(compareData.length > 0 ? { - 'Poverty Rate (cmp)': buildPoint(compareData, year)['Poverty Rate'], + 'Unemployment Rate (cmp)': buildPoint(compareData, year)['Unemployment Rate'], } : {}), })); @@ -376,7 +376,7 @@ export const PovertyTrendChart = ({ ({ <> ({ }; - - - - - - - - - - - // --------------------------------------------------------------------------- // Generic two-location trend chart for the DP-combined explorer // data: [{year, Value}] for side A diff --git a/frontend/src/components/Charts/configs/ChartDefs.tsx b/frontend/src/components/Charts/configs/ChartDefs.tsx index 46a69c5..8561421 100644 --- a/frontend/src/components/Charts/configs/ChartDefs.tsx +++ b/frontend/src/components/Charts/configs/ChartDefs.tsx @@ -179,6 +179,22 @@ export const chartDefs: ChartDef[] = [ extraParams: { year_min: 2010, year_max: 2023 }, }, }, + // Unemployment Rate + { + id: 'unemployment_rate', + title: 'Unemployment Rate — Percent', + url: `${BASE_API_URL}/load/acs5-db/tidy/unemployment-rate`, + xField: '', + yField: '', + subtype: 'renderTable', + trendChart: 'UnemploymentTrendChart', + categories: ['Labor & Economy'], + filterKey: '', + dataKey: '', + tableConfig: { + extraParams: { year_min: 2010, year_max: 2023 }, + }, + }, // Employment (QCEW quarterly, stacked by sector) { id: 'employment', From 376dfc5fd53f4539a4becce0269c72a0119d0caa Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Mon, 1 Jun 2026 13:33:47 -0400 Subject: [PATCH 3/8] finalized unemployment rate trend plot --- .../api/routes/post_routes/post_acs5_db.py | 45 +++++++++++++++---- .../src/components/Charts/TrendCharts.tsx | 7 +-- frontend/src/components/Charts/index.tsx | 1 + 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index f980ee2..8748e62 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -190,28 +190,57 @@ async def tidy_unemployment_rate(request: FilterRequest): if request.name.lower() == "vermont": rows = DB.execute( """ - SELECT year, NAME, Unemployment_Rate + SELECT year, Unemployment_Rate AS Value, Unemployment_Rate AS Percent FROM unemployment_rate WHERE NAME LIKE '%County, Vermont' AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Unemployment_Rate + ORDER BY year """, [request.year_min, request.year_max], ).df() # Average unemployment rate across counties for state level if not rows.empty: - rows = rows.groupby(['year'], as_index=False).agg({ - 'Unemployment_Rate': 'mean', - 'NAME': 'first' - }) + rows = rows.groupby(['year'], as_index=False).agg( + {'Value': 'mean', 'Percent': 'mean'}) + rows['NAME'] = 'Vermont' + elif request.name.lower().endswith(" county, vermont") and request.name.count(',') == 1: + # County-level: aggregate town-level data for the specified county + rows = DB.execute( + """ + SELECT year, Unemployment_Rate AS Value, Unemployment_Rate AS Percent + FROM unemployment_rate + WHERE NAME LIKE ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [f"%{request.name}%", request.year_min, request.year_max], + ).df() + if not rows.empty: + rows = rows.groupby(['year'], as_index=False).agg( + {'Value': 'mean', 'Percent': 'mean'}) + rows['NAME'] = request.name + elif request.name.count(',') >= 2: + # Town-level: names in unemployment_rate include suffixes like "city" or "town" + town_name, rest = request.name.split(',', 1) + rows = DB.execute( + """ + SELECT year, NAME, Unemployment_Rate AS Value, Unemployment_Rate AS Percent + FROM unemployment_rate + WHERE NAME LIKE ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [f"{town_name.strip()}%{rest.strip()}", + request.year_min, request.year_max], + ).df() else: rows = DB.execute( """ - SELECT year, NAME, Unemployment_Rate + SELECT year, NAME, Unemployment_Rate AS Value, Unemployment_Rate AS Percent FROM unemployment_rate WHERE NAME = ? AND CAST(year AS INTEGER) BETWEEN ? AND ? - ORDER BY year, Unemployment_Rate + ORDER BY year """, [request.name, request.year_min, request.year_max], ).df() diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index adf0c02..f8dbf2c 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -343,11 +343,9 @@ export const UnemploymentTrendChart = ({ const cmpName = labels?.[1] ?? 'Comparison'; const buildPoint = (rows: any[], year: number) => { - const find = (label: string) => - rows.find((r) => r.year === year && r.Variable === label)?.Percent ?? - null; + const row = rows.find((r) => r.year === year); return { - 'Unemployment Rate': find('Unemployment Rate'), + 'Unemployment Rate': row?.Value ?? null, }; }; @@ -401,7 +399,6 @@ export const UnemploymentTrendChart = ({ ); }; - // --------------------------------------------------------------------------- // Generic two-location trend chart for the DP-combined explorer // data: [{year, Value}] for side A diff --git a/frontend/src/components/Charts/index.tsx b/frontend/src/components/Charts/index.tsx index 3e49ebd..0e8e6df 100644 --- a/frontend/src/components/Charts/index.tsx +++ b/frontend/src/components/Charts/index.tsx @@ -15,6 +15,7 @@ export { DemographicsTrendChart, EducationTrendChart, HousingTrendChart, + UnemploymentTrendChart, DPTrendChart, } from './TrendCharts'; export { EmploymentAreaChart } from './EmploymentAreaChart'; From 2259b7bf602c650d019c02c20cb8a14033c60f61 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Tue, 2 Jun 2026 11:37:41 -0400 Subject: [PATCH 4/8] added unemployment plot to `plots.md` --- design/plots.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/design/plots.md b/design/plots.md index 0f0361b..314426c 100644 --- a/design/plots.md +++ b/design/plots.md @@ -40,6 +40,10 @@ These only render when a table-primary `ChartItem` is toggled to Chart view. The **Variables:** `Total Housing Units` (left axis, count), `Renter-Occupied Units` (left axis, count), `Median Home Value` (right axis, dollars). **Plot:** three-line `LineChart` with dual Y-axes. Left axis formatted with `toLocaleString()`; right axis in `$Xk`. +### `UnemploymentTrendChart` +**Variables:** `Unemployment Rate` (left axis, Percentage) +**Plot:** one-line `LineChart` with one Y-axis. Dual-line comparisons are available. + --- ## Employment Area Chart From 1736d8587083d96ce787bb0049e056de07255cad Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Wed, 3 Jun 2026 10:06:42 -0400 Subject: [PATCH 5/8] Added median earnings trend plot --- .../api/routes/post_routes/post_acs5_db.py | 67 +++++++++- design/plots.md | 4 + .../src/components/Charts/TrendCharts.tsx | 119 ++++++++++++++++++ .../components/Charts/configs/ChartDefs.tsx | 16 +++ frontend/src/components/Charts/index.tsx | 1 + 5 files changed, 206 insertions(+), 1 deletion(-) diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 8748e62..695c603 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -12,7 +12,7 @@ router = APIRouter() -def _aggregate_to_state(df: pd.DataFrame) -> pd.DataFrame: +def _aggregate_to_state(df: pd.DataFrame, average=False) -> pd.DataFrame: """Aggregate county-level data to state level by summing Value and averaging Percent.""" if df.empty: return df @@ -21,6 +21,8 @@ def _aggregate_to_state(df: pd.DataFrame) -> pd.DataFrame: for col in df.columns: if col in ['year', 'Section', 'Variable']: agg_dict[col] = 'first' + elif average and col == 'Value': + agg_dict[col] = 'mean' elif col == 'Value': agg_dict[col] = 'sum' elif col == 'Percent': @@ -247,6 +249,69 @@ async def tidy_unemployment_rate(request: FilterRequest): return make_response(data=rows, metadata=get_metadata("unemployment_rate")) +# Median Earnings +@router.post("/load/acs5-db/tidy/median-earnings") +async def tidy_median_earnings(request: FilterRequest): + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, estimate AS Value, variable AS Variable + FROM median_earnings + WHERE NAME LIKE '%County, Vermont' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [request.year_min, request.year_max], + ).df() + # Average median earnings across counties for state level + if not rows.empty: + rows = rows.groupby(['year', 'Variable'], as_index=False).agg( + {'Value': 'mean'}) + rows['NAME'] = 'Vermont' + elif request.name.lower().endswith(" county, vermont") and request.name.count(',') == 1: + # County-level: aggregate town-level data for the specified county + rows = DB.execute( + """ + SELECT year, estimate AS Value, variable AS Variable + FROM median_earnings + WHERE NAME LIKE ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [f"%{request.name}%", request.year_min, request.year_max], + ).df() + if not rows.empty: + rows = rows.groupby(['year', 'Variable'], as_index=False).agg( + {'Value': 'mean'}) + rows['NAME'] = request.name + elif request.name.count(',') >= 2: + town_name, rest = request.name.split(',', 1) + rows = DB.execute( + """ + SELECT year, NAME, estimate AS Value, variable AS Variable + FROM median_earnings + WHERE NAME LIKE ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [f"{town_name.strip()}%{rest.strip()}", + request.year_min, request.year_max], + ).df() + else: + rows = DB.execute( + """ + SELECT year, NAME, estimate AS Value, variable AS Variable + FROM median_earnings + WHERE NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year + """, + [request.name, request.year_min, request.year_max], + ).df() + + return make_response(data=rows, metadata=get_metadata("median_earnings")) + + # --------------------------------------------------------------------------- # DP-series combined explorer (DP02 / DP03 / DP04 / DP05) # --------------------------------------------------------------------------- diff --git a/design/plots.md b/design/plots.md index 314426c..33a8a91 100644 --- a/design/plots.md +++ b/design/plots.md @@ -44,6 +44,10 @@ These only render when a table-primary `ChartItem` is toggled to Chart view. The **Variables:** `Unemployment Rate` (left axis, Percentage) **Plot:** one-line `LineChart` with one Y-axis. Dual-line comparisons are available. +### `EarningsTrendChart` +**Variables:** `Median Earnings` (left axis, $ Amount) +**Plot:** Three-line `LineChart` with one Y-axis. Dual-line comparisons are available. + --- ## Employment Area Chart diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index f8dbf2c..673a3e2 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -399,6 +399,125 @@ export const UnemploymentTrendChart = ({ ); }; +// --------------------------------------------------------------------------- +// Economics: Median Earnings (Male vs Female vs All Workers) +// --------------------------------------------------------------------------- + +export const EarningsTrendChart = ({ + chart, +}: { + chart: ChartItem; +}) => { + const data = chart.data as any[]; + const compareData = (chart.compareData ?? []) as any[]; + if (!data || data.length === 0) return null; + + const years = Array.from(new Set(data.map((r) => r.year))).sort(); + const labels = chart.chartParams?.legendLabels as + | [string, string] + | undefined; + const cmpName = labels?.[1] ?? 'Comparison'; + + const buildPoint = (rows: any[], year: number) => { + const find = (label: string) => + rows.find( + (r) => String(r.year) === String(year) && + r.Variable === label + )?.Value ?? null; + return { + 'Male Full-Time Workers': find('DP03_0093'), + 'Female Full-Time Workers': find('DP03_0094'), + 'All Workers': find('DP03_0092'), + }; + }; + + const plotData = years.map((year) => ({ + year, + ...buildPoint(data, year), + ...(compareData.length > 0 + ? { + 'Male Full-Time Workers (cmp)': buildPoint(compareData, year)['Male Full-Time Workers'], + 'Female Full-Time Workers (cmp)': buildPoint(compareData, year)['Female Full-Time Workers'], + 'All Workers (cmp)': buildPoint(compareData, year)['All Workers'], + } + : {}), + })); + return ( + <> + {compareData.length > 0 && } + + + + + + `$${(v / 1000).toFixed(0)}k`} /> + + value != null? `$${Number(value).toLocaleString('en-US', {maximumFractionDigits: 0,})}`: '—'} /> + + + + + {compareData.length > 0 && ( + <> + + + + + )} + + + + ); +}; + // --------------------------------------------------------------------------- // Generic two-location trend chart for the DP-combined explorer // data: [{year, Value}] for side A diff --git a/frontend/src/components/Charts/configs/ChartDefs.tsx b/frontend/src/components/Charts/configs/ChartDefs.tsx index 8561421..356045f 100644 --- a/frontend/src/components/Charts/configs/ChartDefs.tsx +++ b/frontend/src/components/Charts/configs/ChartDefs.tsx @@ -195,6 +195,22 @@ export const chartDefs: ChartDef[] = [ extraParams: { year_min: 2010, year_max: 2023 }, }, }, + // Median Earnings + { + id: 'earnings', + title: 'Median Earnings - Value', + url: `${BASE_API_URL}/load/acs5-db/tidy/median-earnings`, + xField: '', + yField: '', + subtype: 'renderTableEstimates', + trendChart: 'EarningsTrendChart', + categories: ['Labor & Economy'], + filterKey: '', + dataKey: '', + tableConfig: { + extraParams: { year_min: 2010, year_max: 2023 }, + }, + }, // Employment (QCEW quarterly, stacked by sector) { id: 'employment', diff --git a/frontend/src/components/Charts/index.tsx b/frontend/src/components/Charts/index.tsx index 0e8e6df..8f8083d 100644 --- a/frontend/src/components/Charts/index.tsx +++ b/frontend/src/components/Charts/index.tsx @@ -16,6 +16,7 @@ export { EducationTrendChart, HousingTrendChart, UnemploymentTrendChart, + EarningsTrendChart, DPTrendChart, } from './TrendCharts'; export { EmploymentAreaChart } from './EmploymentAreaChart'; From d0136015f757419576f08f0e090bf2a2d89b9359 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Wed, 3 Jun 2026 10:34:17 -0400 Subject: [PATCH 6/8] fixed some data aggregation issues --- backend/api/routes/post_routes/post_acs5_db.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 695c603..0948d96 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -132,7 +132,7 @@ async def tidy_labor_force(request: FilterRequest): """ SELECT year, Section, Variable, Value, Percent FROM b_economic - WHERE NAME LIKE '%County, Vermont' + WHERE geo_type = 'county' AND Section = 'Labor Force' AND CAST(year AS INTEGER) BETWEEN ? AND ? ORDER BY year, Variable @@ -163,14 +163,14 @@ async def tidy_income(request: FilterRequest): """ SELECT year, Section, Variable, Value, Percent FROM b_economic - WHERE NAME LIKE '%County, Vermont' + WHERE geo_type = 'county' AND Section = 'Income' AND CAST(year AS INTEGER) BETWEEN ? AND ? ORDER BY year, Variable """, [request.year_min, request.year_max], ).df() - rows = _aggregate_to_state(rows) + rows = _aggregate_to_state(rows, average=True) else: rows = DB.execute( """ From 4845c4df77590d619b8a0f14f1357134e1770fbd Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Wed, 3 Jun 2026 12:11:40 -0400 Subject: [PATCH 7/8] added median age trend chart --- .../api/routes/post_routes/post_acs5_db.py | 40 ++++++++-- .../src/components/Charts/TrendCharts.tsx | 76 +++++++++++++++++++ .../components/Charts/configs/ChartDefs.tsx | 15 ++++ frontend/src/components/Charts/index.tsx | 1 + 4 files changed, 127 insertions(+), 5 deletions(-) diff --git a/backend/api/routes/post_routes/post_acs5_db.py b/backend/api/routes/post_routes/post_acs5_db.py index 0948d96..2507a8b 100644 --- a/backend/api/routes/post_routes/post_acs5_db.py +++ b/backend/api/routes/post_routes/post_acs5_db.py @@ -45,7 +45,7 @@ async def tidy_demographics(request: FilterRequest): """ SELECT year, Section, Variable, Value, Percent FROM b10_census - WHERE NAME LIKE '%County, Vermont' + WHERE geo_type = 'county' AND CAST(year AS INTEGER) BETWEEN ? AND ? ORDER BY year, Section, Variable """, @@ -74,7 +74,7 @@ async def tidy_education(request: FilterRequest): """ SELECT year, Section, Variable, Value, Percent FROM b15003_education - WHERE NAME LIKE '%County, Vermont' + WHERE geo_type = 'county' AND CAST(year AS INTEGER) BETWEEN ? AND ? ORDER BY year, Variable """, @@ -95,7 +95,7 @@ async def tidy_education(request: FilterRequest): return make_response(data=rows, metadata=get_metadata("education")) -# Housing +# Housing (TODO: Fix statewide aggregation for housing variables that are not counts, e.g. median rent) @router.post("/load/acs5-db/tidy/housing") async def tidy_housing(request: FilterRequest): if request.name.lower() == "vermont": @@ -103,13 +103,13 @@ async def tidy_housing(request: FilterRequest): """ SELECT year, Section, Variable, Value, Percent FROM b_housing - WHERE NAME LIKE '%County, Vermont' + WHERE geo_type = 'county' AND CAST(year AS INTEGER) BETWEEN ? AND ? ORDER BY year, Variable """, [request.year_min, request.year_max], ).df() - rows = _aggregate_to_state(rows) + rows = _aggregate_to_state(rows, ) else: rows = DB.execute( """ @@ -186,6 +186,36 @@ async def tidy_income(request: FilterRequest): return make_response(data=rows, metadata=get_metadata("income")) +# Median Age +@router.post("/load/acs5-db/tidy/demographics/median-age") +async def tidy_median_age(request: FilterRequest): + # If requesting Vermont (state-level), aggregate all counties + if request.name.lower() == "vermont": + rows = DB.execute( + """ + SELECT year, Section, Variable, Value + FROM b10_census + WHERE geo_type = 'county' AND Variable = 'Median Age' + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Section, Variable + """, + [request.year_min, request.year_max], + ).df() + rows = _aggregate_to_state(rows, average=True) + else: + rows = DB.execute( + """ + SELECT year, Section, Variable, Value, Percent + FROM b10_census + WHERE Variable = 'Median Age' AND NAME = ? + AND CAST(year AS INTEGER) BETWEEN ? AND ? + ORDER BY year, Section, Variable + """, + [request.name, request.year_min, request.year_max], + ).df() + return make_response(data=rows, metadata=get_metadata("demographics")) + + # Unemployment Rate @router.post("/load/acs5-db/tidy/unemployment-rate") async def tidy_unemployment_rate(request: FilterRequest): diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index 673a3e2..a40e044 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -121,6 +121,82 @@ export const DemographicsTrendChart = ({ ); }; + +// --------------------------------------------------------------------------- +// Demographics: Median Age Chart +// --------------------------------------------------------------------------- +export const MedianAgeTrendChart = ({ + chart, +}: { + chart: ChartItem; +}) => { + const data = chart.data as any[]; + const compareData = (chart.compareData ?? []) as any[]; + if (!data || data.length === 0) return null; + + const years = Array.from(new Set(data.map((r) => r.year))).sort(); + const labels = chart.chartParams?.legendLabels as + | [string, string] + | undefined; + const cmpName = labels?.[1] ?? 'Comparison'; + + const buildPoint = (rows: any[], year: number) => { + const find = (label: string) => + rows.find((r) => r.year === year && r.Variable === label)?.Value ?? null; + return {'Median Age': find('Median Age')}; + }; + + const plotData = years.map((year) => ({ + year, + ...buildPoint(data, year), + ...(compareData.length > 0 + ? { + 'Median Age (cmp)': buildPoint(compareData, year)['Median Age'], + } + : {}), + })); + + return ( + <> + {compareData.length > 0 && } + + + + + Number(value).toFixed(0)} /> + val != null ? `${Number(val).toFixed(1)} years` : '—'}/> + + + {compareData.length > 0 && ( + <> + + + )} + + + + ); +}; + // --------------------------------------------------------------------------- // Education: all attainment levels except "Some College, No Degree" // --------------------------------------------------------------------------- diff --git a/frontend/src/components/Charts/configs/ChartDefs.tsx b/frontend/src/components/Charts/configs/ChartDefs.tsx index 356045f..c51d005 100644 --- a/frontend/src/components/Charts/configs/ChartDefs.tsx +++ b/frontend/src/components/Charts/configs/ChartDefs.tsx @@ -90,6 +90,21 @@ export const chartDefs: ChartDef[] = [ extraParams: { year_min: 2010, year_max: 2023 }, }, }, + { + id: 'median_age', + title: 'Median Age', + url: `${BASE_API_URL}/load/acs5-db/tidy/demographics/median-age`, + xField: '', + yField: '', + subtype: 'renderTableEstimates', + trendChart: 'MedianAgeTrendChart', + categories: ['Demographics'], + filterKey: '', + dataKey: '', + tableConfig: { + extraParams: { year_min: 2010, year_max: 2023 }, + }, + }, { id: 'education', title: 'Educational Attainment — Percent', diff --git a/frontend/src/components/Charts/index.tsx b/frontend/src/components/Charts/index.tsx index 8f8083d..20c7e68 100644 --- a/frontend/src/components/Charts/index.tsx +++ b/frontend/src/components/Charts/index.tsx @@ -13,6 +13,7 @@ export { } from './DemographicsTable'; export { DemographicsTrendChart, + MedianAgeTrendChart, EducationTrendChart, HousingTrendChart, UnemploymentTrendChart, From 04cd80f4f47dd8dc12b7d75acdc7acbaf99fe158 Mon Sep 17 00:00:00 2001 From: Isarge05 Date: Wed, 3 Jun 2026 12:16:56 -0400 Subject: [PATCH 8/8] added new chart to `plots.md` --- design/plots.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/design/plots.md b/design/plots.md index 33a8a91..57cb361 100644 --- a/design/plots.md +++ b/design/plots.md @@ -46,7 +46,11 @@ These only render when a table-primary `ChartItem` is toggled to Chart view. The ### `EarningsTrendChart` **Variables:** `Median Earnings` (left axis, $ Amount) -**Plot:** Three-line `LineChart` with one Y-axis. Dual-line comparisons are available. +**Plot:** Three-line `LineChart` (Male vs Female vs All Workers) with one Y-axis. Comparisons are available. + +### `MedianAgeTrendChart` +**Variables:** `Median Age` (left axis, Age in years) +**Plot:** One-line `LineChart` with one Y-axis. Dual-line comparisons are available. ---