diff --git a/backend/tests/test_sql_render.py b/backend/tests/test_sql_render.py index 76fa137b..ee978ff3 100644 --- a/backend/tests/test_sql_render.py +++ b/backend/tests/test_sql_render.py @@ -12,7 +12,7 @@ from sqlfluff.core import FluffConfig, Linter from api.models import FilterSource, RangeFilter -from sql_render import ( +from app_utils.sql_render import ( compile_filters, compile_where, filter_clauses, diff --git a/frontend/src/app/data-viewer/MetricsPanels.tsx b/frontend/src/app/data-viewer/MetricsPanels.tsx index 93c777e4..7abdedb6 100644 --- a/frontend/src/app/data-viewer/MetricsPanels.tsx +++ b/frontend/src/app/data-viewer/MetricsPanels.tsx @@ -35,7 +35,6 @@ export function MetricsPanel({ metrics }: { metrics: Record }) { {METRICS.map(({ label, field, prefix }) => ( - s {prefix} {formatNumber(metrics[field])} diff --git a/frontend/src/app/data-viewer/page.tsx b/frontend/src/app/data-viewer/page.tsx index 376b66c2..633ce2e5 100644 --- a/frontend/src/app/data-viewer/page.tsx +++ b/frontend/src/app/data-viewer/page.tsx @@ -27,6 +27,7 @@ import { useApplyFilters, buildFilters, } from '@/components/FilterUI/useApplyFilters'; +import { DataRow, ChartPayload, ChartMetadata } from '@/types/cachedCharts'; import { motion } from 'motion/react'; import classes from './Tabs.module.css'; @@ -583,9 +584,9 @@ export default function DataViewerPage() { charts={visibleItems} action="add" userInterests={interests} - defIds={visibleItems.map( - (c) => c.chartParams?.defId as string | undefined, - )} + defIds={visibleItems + .map((c) => c.chartParams?.defId) + .filter((id): id is string => id !== undefined)} view="gallery" /> diff --git a/frontend/src/app/resources/data-sources/page.tsx b/frontend/src/app/resources/data-sources/page.tsx index fd302517..5e52c727 100644 --- a/frontend/src/app/resources/data-sources/page.tsx +++ b/frontend/src/app/resources/data-sources/page.tsx @@ -30,12 +30,21 @@ import { import { ArrowLeftIcon, MagnifyingGlassIcon } from '@phosphor-icons/react'; import * as motion from 'motion/react-client'; import { + DATA_SOURCES, Category, Dataset, - DATA_SOURCES, Variable, } from './source_description'; +const COLOR = { + spruce: '#1B3A2F', + spruceDeep: '#122820', + slate: '#40525A', + birch: '#F6F5EF', + amberSoft: '#E7B563', + line: 'rgba(27, 58, 47, 0.14)', +}; + import { COLORS, FONTS } from '@/app/theme'; // ----------------------------------------------------------------------------- diff --git a/frontend/src/app/working-report/page.tsx b/frontend/src/app/working-report/page.tsx index 1e8d3c82..4c6dcafd 100644 --- a/frontend/src/app/working-report/page.tsx +++ b/frontend/src/app/working-report/page.tsx @@ -26,15 +26,14 @@ import { XIcon, } from '@phosphor-icons/react'; import { ChartDef, chartDefs } from '@/components/Charts/configs/ChartDefs'; -import { ChartItem, ChartMetadata, DataRow } from '@/types/cachedCharts'; +import { + ChartItem, + ChartMetadata, + DataRow, + ChartPayload, +} from '@/types/cachedCharts'; import { COLORS, FONTS } from '@/app/theme'; -// one chart's backend payload, keyed by chart def id in state below -type ChartPayload = { - data: DataRow[]; - metadata?: ChartMetadata; - tableData?: DataRow[]; -}; import { createChartItem, createTableItem } from '@/utils/itemFactory'; import { useItems } from '@/components/ItemsProvider'; import { PdfModeContext } from '@/contexts/PdfModeContext'; diff --git a/frontend/src/components/Charts/Bar.tsx b/frontend/src/components/Charts/Bar.tsx index 0f0df01c..1e3f144a 100644 --- a/frontend/src/components/Charts/Bar.tsx +++ b/frontend/src/components/Charts/Bar.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useEffect } from 'react'; // recharts import { @@ -214,24 +214,54 @@ const CompareDiffPerXBarChartSVG = ({ const CompareDiffPerXBarChart = ({ chart, + view, + onPlotData, }: { chart: CompareDiffChartItem; + view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => { const isPdfMode = usePdfMode(); - if (isPdfMode) return ; const includeCategories = chart.chartParams?.includeCategories; - const filteredData = includeCategories - ? chart.data.filter((entry: any) => - includeCategories.includes(entry[chart.xField]), - ) - : chart.data; - - const filteredCompareData = includeCategories - ? chart.compareData.filter((entry: any) => - includeCategories.includes(entry[chart.xField]), - ) - : chart.compareData; + + // Filter primary dataset based on categories if specified + const filteredData = useMemo(() => { + return includeCategories + ? chart.data.filter((entry: any) => + includeCategories.includes(entry[chart.xField]), + ) + : chart.data; + }, [chart.data, includeCategories, chart.xField]); + + // Filter comparison dataset based on categories if specified + const filteredCompareData = useMemo(() => { + return includeCategories && chart.compareData + ? chart.compareData.filter((entry: any) => + includeCategories.includes(entry[chart.xField]), + ) + : (chart.compareData ?? []); + }, [chart.compareData, includeCategories, chart.xField]); + + // Derive exact plottable rows and report back to ChartCard for TableView + const plotData = useMemo(() => { + return filteredData.map((entry: any, i: number) => { + const cmpEntry = filteredCompareData[i]; + return { + [chart.xField]: entry[chart.xField], + [chart.yField]: entry[chart.yField], + ...(cmpEntry + ? { [`${chart.yField} (cmp)`]: cmpEntry[chart.yField] } + : {}), + }; + }); + }, [filteredData, filteredCompareData, chart.xField, chart.yField]); + + useEffect(() => { + onPlotData?.(plotData); + }, [plotData, onPlotData]); + + if (isPdfMode) return ; const labels = filteredData.map((entry: any) => entry[chart.xField]); @@ -252,6 +282,7 @@ const CompareDiffPerXBarChart = ({ chart.yField, `${chart.yField} (compare)`, ]; + const data = { labels, datasets: [ @@ -356,35 +387,40 @@ const cleanUseType = (useType: string) => useType.replace(/_/g, ' '); const ZoningAllowanceStackedBarChart = ({ chart, + onPlotData, }: { chart: CompareDiffChartItem; + onPlotData?: (rows: DataRow[]) => void; }) => { // const isPdfMode = usePdfMode(); // if (isPdfMode) return ; - const mainRows = ((chart.data || []) as AllowanceRow[]).filter((r) => - INCLUDED_USE_TYPES.has(r.use_type), + const mainRows = useMemo( + () => + ((chart.data || []) as AllowanceRow[]).filter((r) => + INCLUDED_USE_TYPES.has(r.use_type), + ), + [chart.data], ); - const compareRows = ((chart.compareData || []) as AllowanceRow[]).filter( - (r) => INCLUDED_USE_TYPES.has(r.use_type), + const compareRows = useMemo( + () => + ((chart.compareData || []) as AllowanceRow[]).filter((r) => + INCLUDED_USE_TYPES.has(r.use_type), + ), + [chart.compareData], ); type PivotRow = { use_type: string } & Record; const pivot = (rows: AllowanceRow[]): Record => { const map: Record = {}; - for (const r of rows) { const useType = cleanUseType(r.use_type); const group = groupVal(r.val); - - if (!map[useType]) { - map[useType] = { use_type: useType }; - } + if (!map[useType]) map[useType] = { use_type: useType }; map[useType][group] = ((map[useType][group] as number) || 0) + (Number(r.Acres) || 0); } - return map; }; @@ -401,6 +437,32 @@ const ZoningAllowanceStackedBarChart = ({ const stackKeys = VAL_ORDER; + const plotData = useMemo(() => { + return labels.map((useType) => { + const row: DataRow = { 'Residential Type': useType }; + + stackKeys.forEach((key) => { + row[key] = main[useType]?.[key] ?? 0; + }); + + if (Object.keys(compare).length > 0) { + stackKeys.forEach((key) => { + row[`${key} (cmp)`] = compare[useType]?.[key] ?? 0; + }); + } + + return row; + }); + }, [labels, main, compare, stackKeys]); + + // 3. Serialize plotData in the dependency array to break reference-equality loops! + const serializedPlotData = JSON.stringify(plotData); + + useEffect(() => { + onPlotData?.(plotData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serializedPlotData, onPlotData]); + const colorForGroup = (group: string) => VAL_GROUP_COLORS[group] ?? '#999999'; const mutedColor = (hex: string) => { @@ -429,68 +491,85 @@ const ZoningAllowanceStackedBarChart = ({ [stackKeys, labels, main, compare, colorForGroup], ); - const data = { labels, datasets }; + const data = useMemo( + () => ({ + labels, + datasets, + }), + [labels, datasets], + ); - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - labels: { - generateLabels: (chart: any) => - stackKeys.map((key) => { - const mainIndex = chart.data.datasets.findIndex( - (ds: any) => ds.label === `${key}`, - ); - const compareIndex = chart.data.datasets.findIndex( - (ds: any) => ds.label === `${key}`, - ); - - const mainMeta = chart.getDatasetMeta(mainIndex); - const compareMeta = chart.getDatasetMeta(compareIndex); - - const hidden = - (mainMeta.hidden ?? chart.data.datasets[mainIndex].hidden) && - (compareMeta.hidden ?? - chart.data.datasets[compareIndex].hidden); - - return { - text: key, - fillStyle: colorForGroup(key), - strokeStyle: colorForGroup(key), - lineWidth: 1, - hidden, - datasetIndex: mainIndex, - }; - }), - }, - onClick: (_e: any, legendItem: any, legend: any) => { - const chart = legend.chart; - const key = legendItem.text; - - chart.data.datasets.forEach((ds: any, idx: number) => { - if (ds.label?.startsWith(key)) { - const meta = chart.getDatasetMeta(idx); - meta.hidden = !(meta.hidden ?? false); - } - }); - - chart.update(); + const options = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + animation: { duration: 250 }, + transitions: { + active: { + animation: { + duration: 0, // Disables jerky animation when hovering over bars + }, }, }, - tooltip: { - callbacks: { - label: (ctx: any) => - `${ctx.dataset.label}: ${ctx.raw?.toLocaleString?.() ?? ctx.raw}`, + plugins: { + legend: { + display: true, + labels: { + generateLabels: (chart: any) => + stackKeys.map((key) => { + const mainIndex = chart.data.datasets.findIndex( + (ds: any) => ds.label === `${key}`, + ); + const compareIndex = chart.data.datasets.findIndex( + (ds: any) => ds.label === `${key}`, + ); + + const mainMeta = chart.getDatasetMeta(mainIndex); + const compareMeta = chart.getDatasetMeta(compareIndex); + + const hidden = + (mainMeta.hidden ?? chart.data.datasets[mainIndex].hidden) && + (compareMeta.hidden ?? + chart.data.datasets[compareIndex].hidden); + + return { + text: key, + fillStyle: colorForGroup(key), + strokeStyle: colorForGroup(key), + lineWidth: 1, + hidden, + datasetIndex: mainIndex, + }; + }), + }, + onClick: (_e: any, legendItem: any, legend: any) => { + const chart = legend.chart; + const key = legendItem.text; + + chart.data.datasets.forEach((ds: any, idx: number) => { + if (ds.label?.startsWith(key)) { + const meta = chart.getDatasetMeta(idx); + meta.hidden = !(meta.hidden ?? false); + } + }); + + chart.update(); + }, + }, + tooltip: { + callbacks: { + label: (ctx: any) => + `${ctx.dataset.label}: ${ctx.raw?.toLocaleString?.() ?? ctx.raw}`, + }, }, }, - }, - scales: { - x: { stacked: true }, - y: { stacked: true }, - }, - }; + scales: { + x: { stacked: true }, + y: { stacked: true }, + }, + }), + [stackKeys], + ); return ; }; diff --git a/frontend/src/components/Charts/TableView.tsx b/frontend/src/components/Charts/TableView.tsx index b7b21807..094ae092 100644 --- a/frontend/src/components/Charts/TableView.tsx +++ b/frontend/src/components/Charts/TableView.tsx @@ -1,49 +1,249 @@ // TableView.tsx - +import { useState, useMemo } from 'react'; +import { + Box, + Button, + Group, + ScrollArea, + Table, + Text, + Title, + SegmentedControl, +} from '@mantine/core'; import { ChartItem, DataRow } from '@/types/cachedCharts'; -import { Table } from '@mantine/core'; -import { Group, Title, SegmentedControl, ScrollArea } from '@mantine/core'; interface TableViewProps { chart: ChartItem; + rows?: DataRow[]; // derived/reshaped rows (e.g. trend plotData) take priority } +const HOME_BG = 'var(--mantine-color-green-0)'; +const COMP_BG = 'var(--mantine-color-blue-0)'; +const SPLIT_BORDER = '1px solid var(--mantine-color-gray-3)'; +const CMP_SUFFIX = ' (cmp)'; + +const formatCell = (v: unknown) => { + if (v == null) return '—'; + if (typeof v === 'number') + return v.toLocaleString(undefined, { maximumFractionDigits: 1 }); + return String(v); +}; + export const TableView = ({ chart, + rows: rowsOverride, }: TableViewProps) => { - const rows = (chart.tableData || chart.data) as TData[]; + const rows = (rowsOverride ?? chart.tableData ?? chart.data) as DataRow[]; + const [showCompare, setShowCompare] = useState(false); + const usePivot = !!chart.trendChart; if (!rows || rows.length === 0) return null; - // start with show cols, fall back to x and y field provided they're in there. - const columns = - chart.showCols?.filter((c) => c.visible ?? true) ?? - (rows[0] ? [chart.xField, chart.yField].filter((k) => k in rows[0]) : []); + const labels = chart.chartParams?.legendLabels as + [string, string] | undefined; + const homeLabel = labels?.[0] ?? 'Primary'; + const compareLabel = labels?.[1] ?? 'Comparison'; + + // Check if comparison data exists in rows (keys ending with ' (cmp)') + const allKeys = rows[0] ? Object.keys(rows[0]) : []; + const cmpKeys = allKeys.filter((k) => k.endsWith(CMP_SUFFIX)); + const hasCompare = + cmpKeys.length > 0 && rows.some((r) => cmpKeys.some((k) => r[k] != null)); + + // Render the comparison toggle header control + const ComparisonToggleHeader = () => + hasCompare ? ( + + + {showCompare && ( + + + {homeLabel} + + {compareLabel} + + )} + + ) : null; + + // --- MODE A: PIVOTED TABLE (FOR TREND CHARTS) --- + if (usePivot) { + const xKey = + chart.xField && rows[0] && chart.xField in rows[0] + ? chart.xField + : 'year'; + + const xValues = Array.from(new Set(rows.map((r) => r[xKey]))).filter( + (v) => v != null, + ); + + const seriesKeys = allKeys.filter( + (k) => k !== xKey && !k.endsWith(CMP_SUFFIX), + ); + + const findRow = (x: unknown) => rows.find((r) => r[xKey] === x); + + return ( + + + + + + + + {xValues.map((x) => ( + {String(x)} + ))} + + + + {seriesKeys.map((key) => ( + + {key} + {xValues.map((x) => { + const row = findRow(x); + const mainVal = row?.[key]; + if (!showCompare || !hasCompare) { + return ( + + {formatCell(mainVal)} + + ); + } + const cmpVal = row?.[`${key}${CMP_SUFFIX}`]; + return ( + +
+
+ {formatCell(mainVal)} +
+
+ {formatCell(cmpVal)} +
+
+
+ ); + })} +
+ ))} +
+
+
+
+ ); + } + + // --- MODE B: DIRECT TABLE (FOR BAR CHARTS) --- + // Determine standard columns vs comparison columns + const baseColumns = allKeys.filter((k) => !k.endsWith(CMP_SUFFIX)); return ( - - - - {columns.map((col) => - typeof col === 'string' ? ( - {col} - ) : ( - {col.label ?? col.key} - ), - )} - - - - {rows.map((row, i) => ( - - {columns.map((col) => { - const key = typeof col === 'string' ? col : col.key; - return {String(row[key])}; - })} - - ))} - -
+ + + + + + + {baseColumns.map((column) => { + const hasColCompare = + hasCompare && + rows.some((r) => r[`${column}${CMP_SUFFIX}`] != null); + + return {column}; + })} + + + + + {rows.map((row, i) => ( + + {baseColumns.map((column) => { + const mainVal = row[column]; + const cmpVal = row[`${column}${CMP_SUFFIX}`]; + const isComparableCell = + hasCompare && showCompare && cmpVal != null; + + if (isComparableCell) { + return ( + +
+
+ {formatCell(mainVal)} +
+
+ {formatCell(cmpVal)} +
+
+
+ ); + } + + return ( + {formatCell(mainVal)} + ); + })} +
+ ))} +
+
+
+
); }; diff --git a/frontend/src/components/Charts/TrendCharts.tsx b/frontend/src/components/Charts/TrendCharts.tsx index 2025baa9..29c635ad 100644 --- a/frontend/src/components/Charts/TrendCharts.tsx +++ b/frontend/src/components/Charts/TrendCharts.tsx @@ -1,7 +1,7 @@ // TrendCharts.tsx 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { CartesianGrid, Legend, @@ -115,10 +115,12 @@ export const SingleSeriesTrendChart = ({ chart, config, view, + onPlotData, }: { chart: ChartItem; config: SingleSeriesConfig; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => { const isGallery = view === 'gallery'; const { @@ -135,13 +137,11 @@ export const SingleSeriesTrendChart = ({ const { hidden, toggleSeries, legendFormatter } = useToggle(); const data = chart.data as any[]; - const compareData = (chart.compareData ?? []) as any[]; - if (!data || data.length === 0) return null; + const compareData = chart.compareData as any[]; - const years = Array.from(new Set(data.map((r) => r.year))).sort(); + const seriesName = seriesKey ?? valueField; const labels = chart.chartParams?.legendLabels as [string, string] | undefined; - const seriesName = seriesKey ?? valueField; const findValue = (rows: any[], year: number) => { const row = seriesKey @@ -150,13 +150,27 @@ export const SingleSeriesTrendChart = ({ return row?.[valueField] ?? null; }; - const plotData = years.map((year) => ({ - year, - [seriesName]: findValue(data, year), - ...(compareData.length > 0 - ? { [`${seriesName} (cmp)`]: findValue(compareData, year) } - : {}), - })); + const years = useMemo( + () => (data ? Array.from(new Set(data.map((r) => r.year))).sort() : []), + [data], + ); + + const plotData = useMemo(() => { + if (!data || data.length === 0) return []; + return years.map((year) => ({ + year, + [seriesName]: findValue(data, year), + ...(compareData && compareData.length > 0 + ? { [`${seriesName} (cmp)`]: findValue(compareData, year) } + : {}), + })); + }, [years, data, compareData, seriesName]); + + useEffect(() => { + onPlotData?.(plotData); + }, [plotData, onPlotData]); + + if (!data || data.length === 0) return null; // early return now AFTER all hooks const fmt = FORMATTERS[format]; @@ -256,13 +270,14 @@ export const MultiSeriesTrendChart = ({ chart, config, view, + onPlotData, }: { chart: ChartItem; config: MultiSeriesConfig; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => { const isGallery = view === 'gallery'; - const { series, valueField, @@ -276,10 +291,7 @@ export const MultiSeriesTrendChart = ({ const { hidden, toggleSeries, legendFormatter } = useToggle(); 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 compareData = chart.compareData as any[]; const labels = chart.chartParams?.legendLabels as [string, string] | undefined; @@ -301,15 +313,29 @@ export const MultiSeriesTrendChart = ({ ); }; - const plotData = years.map((year) => { - const pt: Record = { year }; - for (const s of series) { - pt[s.key] = getValue(data, year, s); - if (compareData.length > 0) - pt[`${s.key} (cmp)`] = getValue(compareData, year, s); - } - return pt; - }); + const years = useMemo( + () => (data ? Array.from(new Set(data.map((r) => r.year))).sort() : []), + [data], + ); + + const plotData = useMemo(() => { + if (!data || data.length === 0) return []; + return years.map((year) => { + const pt: Record = { year }; + for (const s of series) { + pt[s.key] = getValue(data, year, s); + if (compareData && compareData.length > 0) + pt[`${s.key} (cmp)`] = getValue(compareData, year, s); + } + return pt; + }); + }, [years, data, compareData, JSON.stringify(series), valueField]); + + useEffect(() => { + onPlotData?.(plotData); + }, [plotData, onPlotData]); + + if (!data || data.length === 0) return null; const fmt = FORMATTERS[format]; @@ -394,66 +420,88 @@ const single = ( chart: ChartItem, config: SingleSeriesConfig, view?: 'gallery' | 'report', -) => ; + onPlotData?: (rows: DataRow[]) => void, +) => ( + +); export const PopulationTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, { seriesKey: null, valueField: 'Population', format: 'number' }, view, + onPlotData, ); export const MedianAgeTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, { seriesKey: 'Median Age', valueField: 'Value', format: 'years' }, view, + onPlotData, ); export const HomeValueTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, { seriesKey: 'Median Home Value', valueField: 'Value', format: 'currency' }, view, + onPlotData, ); export const HousingUnitsTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, { seriesKey: 'Total Housing Units', valueField: 'Value', format: 'number' }, view, + onPlotData, ); export const HousingTenureAreaChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -463,14 +511,17 @@ export const HousingTenureAreaChart = ({ format: 'percent', }, view, + onPlotData, ); export const LaborForceTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -480,14 +531,17 @@ export const LaborForceTrendChart = ({ format: 'percent', }, view, + onPlotData, ); export const LaborForceTrendChartPrimeAge = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -497,14 +551,17 @@ export const LaborForceTrendChartPrimeAge = ({ format: 'percent', }, view, + onPlotData, ); export const UnemploymentTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -515,14 +572,17 @@ export const UnemploymentTrendChart = ({ decimals: 1, }, view, + onPlotData, ); export const HouseholdIncomeTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -533,14 +593,17 @@ export const HouseholdIncomeTrendChart = ({ showHelperText: false, }, view, + onPlotData, ); export const PerCapitaIncomeTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => single( chart, @@ -551,6 +614,7 @@ export const PerCapitaIncomeTrendChart = ({ showHelperText: false, }, view, + onPlotData, ); // MULTI CHARTS @@ -558,14 +622,24 @@ const multi = ( chart: ChartItem, config: MultiSeriesConfig, view?: 'gallery' | 'report', -) => ; + onPlotData?: (rows: DataRow[]) => void, +) => ( + +); export const DemographicsTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => multi( chart, @@ -582,14 +656,17 @@ export const DemographicsTrendChart = ({ ], }, view, + onPlotData, ); export const EducationTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => multi( chart, @@ -607,14 +684,17 @@ export const EducationTrendChart = ({ ], }, view, + onPlotData, ); export const EarningsTrendChart = ({ chart, view, + onPlotData, }: { chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }) => multi( chart, @@ -636,6 +716,7 @@ export const EarningsTrendChart = ({ ], }, view, + onPlotData, ); // --------------------------------------------------------------------------- diff --git a/frontend/src/components/Charts/configs/ChartDefs.tsx b/frontend/src/components/Charts/configs/ChartDefs.tsx index 194df267..0f83c4f1 100644 --- a/frontend/src/components/Charts/configs/ChartDefs.tsx +++ b/frontend/src/components/Charts/configs/ChartDefs.tsx @@ -293,7 +293,7 @@ export const chartDefs: ChartDef[] = [ // Median Earnings { id: 'earnings', - title: 'Median Earnings - Value', + title: 'Median Earnings by Sex', url: `${BASE_API_URL}/load/acs5-db/tidy/median-earnings`, xField: '', yField: '', diff --git a/frontend/src/components/Charts/index.tsx b/frontend/src/components/Charts/index.tsx index 272e5ffa..3c601a4e 100644 --- a/frontend/src/components/Charts/index.tsx +++ b/frontend/src/components/Charts/index.tsx @@ -43,6 +43,7 @@ import { ActionIcon, Modal, Container, + ScrollArea, } from '@mantine/core'; import { CornersOutIcon, CornersInIcon } from '@phosphor-icons/react'; import * as motion from 'motion/react-client'; @@ -57,10 +58,12 @@ interface ChartCardProps { ChartComponent: React.FC<{ chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }>; TrendComponent?: React.FC<{ chart: ChartItem; view?: 'gallery' | 'report'; + onPlotData?: (rows: DataRow[]) => void; }>; matchedCategories?: string[]; action?: 'add' | 'remove' | 'toggle'; @@ -97,18 +100,34 @@ export const ChartCard = ({ !isGallery && (isTablePrimary ? !!TrendComponent : true); + const [trendPlotData, setTrendPlotData] = useState(); + const content = selfManagesViews ? ( ) : isTablePrimary ? ( localView === 'chart' && TrendComponent ? ( - + + ) : TrendComponent ? ( + ) : ( ) - ) : localView === 'chart' ? ( - ) : ( - + <> + + + + + {localView === 'table' && ( + + + + )} + ); const isHighlighted = matchedCategories.length > 0; @@ -131,7 +150,6 @@ export const ChartCard = ({ padding={isGallery ? 'sm' : 'lg'} radius="md" withBorder={showBorder} - display="flex" data-chart-id={chart.id} data-chart-subtype={chart.subtype} onMouseEnter={isGallery ? () => setIsHovered(true) : undefined} @@ -145,8 +163,6 @@ export const ChartCard = ({ : undefined } style={{ - flexDirection: 'column', - minHeight: 0, ...(isHighlighted ? { borderColor: '#154734', borderWidth: 2 } : {}), breakInside: 'avoid', pageBreakInside: 'avoid', @@ -224,17 +240,18 @@ export const ChartCard = ({ - - {content} + + + {content} + {!isGallery && ( diff --git a/frontend/src/components/Charts/saving.tsx b/frontend/src/components/Charts/saving.tsx index 6971eb51..ad0641c2 100644 --- a/frontend/src/components/Charts/saving.tsx +++ b/frontend/src/components/Charts/saving.tsx @@ -31,20 +31,38 @@ interface AddChartProps { chart: ChartItem; defId?: string; } -export function AddChart({ chart, defId }: AddChartProps) { - const { addItem, removeItem, includeById, excludeById, items } = useItems(); +export function AddChart({ chart, defId }: AddChartProps) { + const { addItem, removeItem, items } = useItems(); const { interests } = useProfile(); - const stableId = [ - chart.title, - chart.subtype, - ...(chart.chartParams?.legendLabels ?? []), - ].join('::'); + const stableId = + defId ?? + chart.id ?? + [ + chart.title, + chart.subtype, + chart.chartParams?.xKey, + chart.chartParams?.yKey, + ...(chart.chartParams?.legendLabels ?? []), + ] + .filter(Boolean) + .join('::'); + + // Check if the chart categories match any of the user's profile interests + const chartCategories = chart.categories ?? []; // Adjust field name to match your ChartItem schema + const matchesInterests = + interests.length === 0 || // If no interests set, default to showing/including + chartCategories.some((category) => interests.includes(category)); + // Auto-exclude initial state check based on interests if needed, + // or track state relative to items in report: const inReport = items.some((item) => item.id === stableId); - const handleClick = () => { + const handleClick = (e: React.MouseEvent) => { + // Prevent parent elements (like ChartCard or Modal triggers) from catching the click + e.stopPropagation(); + if (inReport) { removeItem(stableId); } else { @@ -52,6 +70,11 @@ export function AddChart({ chart, defId }: AddChartProps) { } }; + // Optional: If you want to dim or hide the button when interests don't match + if (!matchesInterests && !inReport) { + return null; // or render a muted/disabled state based on your UX needs + } + return (