diff --git a/app/src/api/billFeed.ts b/app/src/api/billFeed.ts index 39db3083e..cf82fe144 100644 --- a/app/src/api/billFeed.ts +++ b/app/src/api/billFeed.ts @@ -189,6 +189,13 @@ function totalRows(response: Response): number | undefined { async function trackerSelect( table: string, select: string, + /** + * A unique column that puts the rows in one fixed order ("bill_id"). + * With no order, the database can give each page its rows in another + * order, so the pages repeat some rows and drop others, and different + * ones on each load. + */ + order: string, /** A PostgREST row filter, e.g. "id=eq.ks-hb2620". */ filter?: string ): Promise { @@ -199,7 +206,7 @@ async function trackerSelect( // Read page by page: one request stops at the cap and drops the rest. const rows: any[] = []; for (;;) { - const query = `select=${select}${filter ? `&${filter}` : ''}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; + const query = `select=${select}${filter ? `&${filter}` : ''}&order=${order}&limit=${TRACKER_PAGE_SIZE}&offset=${rows.length}`; const response = await fetch(`${config.url}/rest/v1/${table}?${query}`, { headers: { apikey: config.anonKey, @@ -500,6 +507,7 @@ export async function fetchTrackerBillDistricts( const rows = await trackerSelect( 'reform_impacts', 'district_impacts', + 'id', `id=eq.${encodeURIComponent(billId)}` ); const raw = rows?.[0]?.district_impacts; @@ -560,11 +568,74 @@ function normalizeBillNumber(value: string): string { const processedBillKey = (state: string, billNumber: string) => `${state.toUpperCase()}:${normalizeBillNumber(billNumber)}`; +/** A status row's last action day ("2026-03-04"), or "" when it has none. */ +const lastActionDay = (bill: any): string => { + const day = typeof bill.last_action_date === 'string' ? bill.last_action_date.slice(0, 10) : ''; + return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : ''; +}; + +/** + * Whether a status row came from LegiScan, read from its link: the + * OpenStates and Congress monitors store their own sites' links in the + * same column, and the row's source column defaults to "legiscan". + */ +const isLegiscanRow = (bill: any): boolean => + /^https?:\/\/(www\.)?legiscan\.com\//i.test(String(bill.legiscan_url ?? '')); + +/** A status row's bill id as a number, lowest when it has none. */ +const statusRowId = (bill: any): number => { + const id = Number(bill.bill_id ?? NaN); + return Number.isFinite(id) ? id : -Infinity; +}; + +const statusRowText = (bill: any) => + JSON.stringify([ + bill.bill_id, + bill.state, + bill.bill_number, + bill.status, + bill.legiscan_url, + bill.last_action_date, + ]); + +const ascending = (a: number | string, b: number | string) => (a < b ? -1 : a > b ? 1 : 0); + +/** + * Orders two status rows for one bill number, the preferred row last. + * The tracker can hold a bill twice under two spellings of its number: + * LegiScan's (NY "S04487") and OpenStates' ("S 4487"). The row with the + * later last action wins. On the same day, or with no dates, the LegiScan + * row wins: it names the stage more finely ("In Committee", where + * OpenStates says "Introduced") and links to LegiScan. The bill id and + * then the row's contents only fix the order of what is left. They do + * not date a bill: OpenStates and Congress ids are hashes, all above + * LegiScan's. The same row wins in any read order. + */ +function compareStatusRows(a: any, b: any): number { + return ( + ascending(lastActionDay(a), lastActionDay(b)) || + ascending(Number(isLegiscanRow(a)), Number(isLegiscanRow(b))) || + ascending(statusRowId(a), statusRowId(b)) || + ascending(statusRowText(a), statusRowText(b)) + ); +} + +/** + * The bill number a title names, as the tracker reads it: the first + * letters-then-number ("SB3125", "H. 3492", "A 1185"), not a fiscal year. + */ +function titleBillNumber(title: unknown): string | undefined { + return typeof title === 'string' ? title.match(/\b(?!FY)([A-Z]{1,3}\.?\s*\d+)/i)?.[1] : undefined; +} + /** * The tracker's status row (processed_bills) for a research entry. The * entry's LegiScan link (legiscan_bill_id) names the row when it is set. * Otherwise the entry's id names the bill: "ut-sb0060", "ny-s-4487", or * "ga-hb463-2026", where a trailing year names the session, not the bill. + * An id with words after the number ("hi-sb3125-hd1", "ut-sb60-bill") + * finds its row as the tracker does: by the bill number in the title, + * then by the id's first part. */ function processedBillFor( record: any, @@ -581,11 +652,24 @@ function processedBillFor( if (!state || parts.length === 0) { return undefined; } - const exact = byKey.get(processedBillKey(state, parts.join(''))); - if (exact || parts.length < 2 || !/^\d{4}$/.test(parts[parts.length - 1])) { - return exact; + const candidates = [parts.join('')]; + if (parts.length >= 2 && /^\d{4}$/.test(parts[parts.length - 1])) { + candidates.push(parts.slice(0, -1).join('')); + } + const titled = titleBillNumber(record.title); + if (titled) { + candidates.push(titled); + } + if (parts.length >= 2 && /^[a-z]+\d+$/i.test(parts[0])) { + candidates.push(parts[0]); } - return byKey.get(processedBillKey(state, parts.slice(0, -1).join(''))); + for (const billNumber of candidates) { + const row = byKey.get(processedBillKey(state, billNumber)); + if (row) { + return row; + } + } + return undefined; } /** @@ -595,19 +679,25 @@ function processedBillFor( export async function fetchTrackerBills(): Promise { const [research, impacts, processed, validations, baselines] = await Promise.all([ // Titled as the tracker shows them: an entry with no title reads as its bill number. - trackerSelect('research', '*').then((rows) => rows?.map(withTrackerTitle) ?? null), + trackerSelect('research', '*', 'id').then((rows) => rows?.map(withTrackerTitle) ?? null), trackerSelect( 'reform_impacts', - 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at' + 'id,reform_params,provisions,computed,budgetary_impact,poverty_impact,child_poverty_impact,winners_losers,decile_impact,policyengine_us_version,dataset_name,dataset_version,computed_at', + 'id' + ), + trackerSelect( + 'processed_bills', + 'bill_id,state,bill_number,status,legiscan_url,last_action_date', + 'bill_id' ), - trackerSelect('processed_bills', 'bill_id,state,bill_number,status,legiscan_url'), trackerSelect( 'validation_metadata', - 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against' + 'id,fiscal_note_source,fiscal_note_url,fiscal_note_estimate,pe_estimate,target_range_low,target_range_high,within_range,difference_from_fiscal_note_pct,discrepancy_explanation,external_analyses,verification,verified_at,validated_against', + 'id' ), // The law each bill was compared with. Read on its own, so a tracker // without the column yet still serves the rest of the feed. - trackerSelect('reform_impacts', 'id,baseline_params').catch(() => null), + trackerSelect('reform_impacts', 'id,baseline_params', 'id').catch(() => null), ]); if (!research) { return null; @@ -621,12 +711,15 @@ export async function fetchTrackerBills(): Promise { bill.bill_id === null || bill.bill_id === undefined ? [] : [[String(bill.bill_id), bill]] ) ); - const processedByKey = new Map( - (processed ?? []).map((bill) => [ - processedBillKey(String(bill.state), String(bill.bill_number)), - bill, - ]) - ); + // Two rows can share a bill number's key: the preferred one holds it. + const processedByKey = new Map(); + for (const bill of processed ?? []) { + const key = processedBillKey(String(bill.state), String(bill.bill_number)); + const held = processedByKey.get(key); + if (!held || compareStatusRows(held, bill) < 0) { + processedByKey.set(key, bill); + } + } return research .filter((record) => isBillRecord(record)) diff --git a/app/src/components/flagship/DeleteReformDialog.tsx b/app/src/components/flagship/DeleteReformDialog.tsx new file mode 100644 index 000000000..e6d7b0cf5 --- /dev/null +++ b/app/src/components/flagship/DeleteReformDialog.tsx @@ -0,0 +1,62 @@ +import { IconTrash } from '@tabler/icons-react'; +import { AlertDialog } from 'radix-ui'; +import { Button } from '@/components/ui'; + +/** What the delete does to the reports run from the reform. */ +function reportsNote(reportCount: number): string { + if (reportCount === 0) { + return ''; + } + return reportCount === 1 + ? ' Its 1 report leaves this list; it still opens from its link.' + : ` Its ${reportCount} reports leave this list; they still open from their links.`; +} + +/** + * The reform detail's Delete button. A delete cannot be undone, and the + * reform's reports are listed only under it, so the button asks first. + */ +export default function DeleteReformDialog({ + name, + reportCount, + disabled, + onConfirm, +}: { + name: string; + /** The reports this browser ran from the reform. */ + reportCount: number; + disabled?: boolean; + onConfirm: () => void; +}) { + return ( + + + + + + + + + Delete “{name}”? + + + This removes the reform for good.{reportsNote(reportCount)} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index bba85bdb9..666174a7d 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { IconArrowLeft, IconCheck, @@ -315,6 +315,9 @@ export default function ParameterSearchBox({ const [query, setQuery] = useState(''); const inputRef = useRef(null); const [highlighted, setHighlighted] = useState(0); + // Option ids, so the input can name the highlighted row while focus stays in it. + const optionIdPrefix = useId(); + const optionId = (index: number) => `${optionIdPrefix}-option-${index}`; const [hoveredFolder, setHoveredFolder] = useState(null); /** * A folder opened from the results: the dropdown shows everything the @@ -475,6 +478,18 @@ export default function ParameterSearchBox({ let runningIndex = -1; + const resultsShown = + !hideResults && (browsing !== null || (flatEntries.length > 0 && !widerOnly)); + // The row the arrow keys point at, for screen readers: none while a + // folder is pointed at or the list is out of date. + const activeOptionId = + resultsShown && + !(!browsing && search.stale) && + highlighted >= 0 && + highlighted < flatEntries.length + ? optionId(highlighted) + : undefined; + const active = query.trim().length > 0 || browsing !== null; const noMatches = !hideResults && @@ -559,6 +574,8 @@ export default function ParameterSearchBox({ role="combobox" aria-expanded={Boolean(browsing) || flatEntries.length > 0} aria-controls="parameter-search-results" + aria-autocomplete="list" + aria-activedescendant={activeOptionId} style={{ flex: 1, minWidth: 0, @@ -736,7 +753,7 @@ export default function ParameterSearchBox({ )} - {!hideResults && (browsing || (flatEntries.length > 0 && !widerOnly)) && ( + {resultsShown && (
(
diff --git a/app/src/components/flagship/report/ReportChanges.tsx b/app/src/components/flagship/report/ReportChanges.tsx index 1f0d68463..45716668a 100644 --- a/app/src/components/flagship/report/ReportChanges.tsx +++ b/app/src/components/flagship/report/ReportChanges.tsx @@ -6,6 +6,7 @@ import ProvisionSkeleton from '@/components/flagship/ProvisionSkeleton'; import { CURRENT_YEAR, FOREVER } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; import { sameParameterValue } from '@/libs/draftReform'; +import { changeNamesPath } from '@/libs/flagship/billProvisions'; import { describeChange, reportProvisionName } from '@/libs/flagship/draftLabels'; import type { RunReportProvision } from '@/libs/flagship/runReport'; import { RootState } from '@/store'; @@ -83,15 +84,6 @@ function provisionDates(provision: Pick): Chang }; } -/** Whether a tracker parameter, which can hold "{filing_status}", names this path. */ -function namesPath(parameter: string, path: string): boolean { - const pattern = parameter - .split(/\{[^}]+\}/) - .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('[^.]+'); - return new RegExp(`^${pattern}$`).test(path); -} - const dateText = (date: string) => dayjs(date).format('MMM D, YYYY'); const endText = (date: string) => (date === FOREVER ? 'No end date' : dateText(date)); @@ -138,7 +130,7 @@ export default function ReportChanges({ : undefined; const changeDates = (change: BillChange): ChangeDates | undefined => { const named = change.parameter - ? provisions.filter((provision) => namesPath(change.parameter!, provision.path)) + ? provisions.filter((provision) => changeNamesPath(change.parameter!, provision.path)) : []; if (named.length > 0) { const dates = named.map(provisionDates); diff --git a/app/src/components/flagship/report/ReportNotSocietyWide.tsx b/app/src/components/flagship/report/ReportNotSocietyWide.tsx new file mode 100644 index 000000000..6fe6efcc7 --- /dev/null +++ b/app/src/components/flagship/report/ReportNotSocietyWide.tsx @@ -0,0 +1,26 @@ +import { ErrorState } from '@/components/flagship/states'; +import { Button } from '@/components/ui'; +import { useAppNavigate } from '@/contexts/NavigationContext'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; + +/** + * A link to a household report, which the classic calculator ran. This + * page shows society-wide results only, so it says so rather than run + * the report, which would score one household and write that over the + * report's stored outputs. + */ +export default function ReportNotSocietyWide() { + const nav = useAppNavigate(); + const countryId = useCurrentCountry(); + return ( + nav.push(`/${countryId}/build`)}> + Back to Build + + } + /> + ); +} diff --git a/app/src/components/flagship/report/StoredImpactCharts.tsx b/app/src/components/flagship/report/StoredImpactCharts.tsx index e771851f0..597763c72 100644 --- a/app/src/components/flagship/report/StoredImpactCharts.tsx +++ b/app/src/components/flagship/report/StoredImpactCharts.tsx @@ -11,9 +11,10 @@ import { } from 'recharts'; import type { TrackedBill, WinnerShares } from '@/api/billFeed'; import ReportChartCard from '@/components/flagship/report/ReportChartCard'; +import type { ReportMetric } from '@/components/flagship/ReportContents'; import { SegmentedControl, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; -import { formatCurrencyAbbr } from '@/utils/formatters'; +import { storedRevenueMetric } from '@/libs/flagship/billMetrics'; const WINNER_SEGMENTS = [ { key: 'gainMore', label: 'Gain more than 5%', color: colors.primary[600] }, @@ -98,28 +99,16 @@ const dollars = (value: number) => const signedPercent = (value: number) => `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value).toFixed(1)}%`; -type StoredBudgetary = NonNullable['budgetary']; - -/** The stored revenue change; as the tracker reads it, net cost stands in where none is stored. */ -const storedRevenue = (budgetary: StoredBudgetary) => - budgetary?.stateRevenueImpact ?? budgetary?.netCost; +type StoredBill = Pick; /** - * The tracker's annual state revenue change, as the report's own headline - * words it: a number, not a chart, across the grid's full width. + * The tracker's annual revenue change, in the overview's own words and + * sign: a number, not a chart, across the grid's full width. */ -function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { - const value = storedRevenue(budgetary); - if (typeof value !== 'number' || !Number.isFinite(value)) { +function StoredBudget({ revenue }: { revenue: ReportMetric | null }) { + if (!revenue) { return null; } - const amount = formatCurrencyAbbr(Math.abs(value), 'us', { maximumFractionDigits: 1 }); - const label = - value < 0 - ? 'Annual state revenue loss' - : value > 0 - ? 'Annual state revenue gain' - : 'Annual state revenue change'; return (
- {value < 0 ? '−' : value > 0 ? '+' : ''} - {amount} + {revenue.value} - {label} + {revenue.label}
@@ -154,8 +142,17 @@ function StoredBudget({ budgetary }: { budgetary: StoredBudgetary }) { * a bill, in the same chart cards as a report's economic impacts. Shown * while the full results calculate, or instead of them when they cannot. */ -export default function StoredImpactCharts({ impact }: { impact: TrackedBill['impactData'] }) { +export default function StoredImpactCharts({ + bill, + countryId = 'us', +}: { + bill: StoredBill; + countryId?: Parameters[1]; +}) { const [decileMode, setDecileMode] = useState<'average' | 'relative'>('average'); + const impact = bill.impactData; + // Worded as the overview words it, so the two read the same. + const revenue = storedRevenueMetric(bill, countryId); const average = decileRows(impact?.decile?.average); const relative = decileRows(impact?.decile?.relative, 100); @@ -189,9 +186,8 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im return change === null ? [] : [{ group, change }]; }); - const hasBudget = typeof storedRevenue(impact?.budgetary) === 'number'; if ( - !hasBudget && + !revenue && average.length + relative.length + winnersRows.length + povertyRows.length === 0 ) { return null; @@ -206,7 +202,7 @@ export default function StoredImpactCharts({ impact }: { impact: TrackedBill['im }} > {/* The budget leads, as it does in the full results. */} - + {(average.length > 0 || relative.length > 0) && ( [`${Number(value ?? 0).toFixed(1)}%`, String(name)]} + // The shares count people, so the All row is everyone. labelFormatter={(label) => - label === 'All' ? 'All households' : label ? `Decile ${label}` : '' + label === 'All' ? 'All people' : label ? `Decile ${label}` : '' } /> {WINNER_SEGMENTS.map((segment) => ( diff --git a/app/src/components/flagship/report/charts/BudgetaryImpactSubPage.tsx b/app/src/components/flagship/report/charts/BudgetaryImpactSubPage.tsx index 380866c78..17f09cd66 100644 --- a/app/src/components/flagship/report/charts/BudgetaryImpactSubPage.tsx +++ b/app/src/components/flagship/report/charts/BudgetaryImpactSubPage.tsx @@ -31,6 +31,16 @@ interface Props { fillHeight?: boolean; } +/** The bars' names where the full ones do not fit under their bars, as on a phone. */ +const SHORT_NAMES: Record = { + 'Federal tax revenues': 'Federal taxes', + 'Tax revenues': 'Taxes', + 'State and local income tax revenues': 'State taxes', + 'State and local income taxes': 'State taxes', + 'Benefit spending': 'Benefits', + 'Net impact': 'Net', +}; + /** "−$296.5m": each bar in its own unit, the minus before the currency symbol. */ function formatBarAmount(value: number, countryId: CountryId): string { const amount = formatCurrencyAbbr(Math.abs(value), countryId, { maximumFractionDigits: 1 }); @@ -109,6 +119,7 @@ export default function BudgetaryImpactSubPage({ formatBarLabel: (value: number) => formatBarAmount(toAmount(value), countryId), fillColor: (datum: WaterfallDatum) => getBudgetFillColor(datum, budgetaryImpact), tooltipContent: , + shortNames: SHORT_NAMES, }; if (fillHeight) { diff --git a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx index de305eafa..0fce525d1 100644 --- a/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx +++ b/app/src/components/flagship/report/charts/DeepPovertyImpactByAgeSubPage.tsx @@ -21,6 +21,13 @@ import { getValueBarLayout, tickDecimals, } from '@/components/flagship/report/charts/barLayout'; +import { categoryAxisProps } from '@/components/flagship/report/charts/categoryAxis'; +import { + rateChangeMessage, + rateChangeTitle, + relativeBar, + relativeChange, +} from '@/components/flagship/report/charts/relativeChange'; import { ReportBarLabel } from '@/components/flagship/report/charts/ReportBarLabel'; import { usePlotSize } from '@/components/flagship/report/charts/usePlotSize'; import { Stack, Text } from '@/components/ui'; @@ -31,13 +38,14 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useViewportSize } from '@/hooks/useViewportSize'; import { getPovertyByAgeCsvRows } from '@/pages/report-output/poverty-impact/povertyChartUtils'; import type { RootState } from '@/store'; -import { relativeChangeMessage } from '@/utils/chartMessages'; import { getClampedChartHeight, getYAxisLayout, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; -import { formatNumber, formatPercent } from '@/utils/formatters'; +import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; /** Margins around the plot; value labels stay inside it, so the top needs little. */ const CHART_MARGIN = { top: 12, right: 20, bottom: 20 }; +/** The short name a compact card shows, and any chart where the full one does not fit. */ +const AGE_SHORT_NAMES: Record = { 'Working-age adults': 'Adults' }; interface Props { output: SocietyWideReportOutput; @@ -61,19 +69,6 @@ export default function DeepPovertyImpactByAgeSubPage({ // Extract data const deepPovertyImpact = output.poverty.deep_poverty; - // Calculate changes for each age group - const childPovertyChange = deepPovertyImpact.child.reform / deepPovertyImpact.child.baseline - 1; - const adultPovertyChange = deepPovertyImpact.adult.reform / deepPovertyImpact.adult.baseline - 1; - const seniorPovertyChange = - deepPovertyImpact.senior.reform / deepPovertyImpact.senior.baseline - 1; - const totalPovertyChange = deepPovertyImpact.all.reform / deepPovertyImpact.all.baseline - 1; - - const povertyChanges = [ - childPovertyChange, - adultPovertyChange, - seniorPovertyChange, - totalPovertyChange, - ]; const povertyLabels = ['Children', 'Working-age adults', 'Seniors', 'All']; const labelToKey: Record = { Children: 'child', @@ -81,6 +76,11 @@ export default function DeepPovertyImpactByAgeSubPage({ Seniors: 'senior', All: 'all', }; + // Each age group's change; none for a group with no one in deep poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = deepPovertyImpact[labelToKey[label]]; + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -91,45 +91,27 @@ export default function DeepPovertyImpactByAgeSubPage({ // Generate hover message const hoverMessage = (x: string) => { const obj = `the percentage of ${x === 'All' ? 'people' : x.toLowerCase()} in deep poverty`; - const baseline = deepPovertyImpact[labelToKey[x]].baseline; - const reform = deepPovertyImpact[labelToKey[x]].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = deepPovertyImpact[labelToKey[x]]; + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = deepPovertyImpact.all.baseline; - const reform = deepPovertyImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the deep poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = deepPovertyImpact.all; + return rateChangeTitle( + 'the deep poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -168,9 +150,10 @@ export default function DeepPovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + {...categoryAxisProps(povertyLabels, plotWidth, { + shortNames: AGE_SHORT_NAMES, + preferShort: compact, + })} /> = { Male: 'male', Female: 'female', All: 'all', }; + const rateOf = (label: string) => { + const key = labelToKey[label]; + return key === 'all' ? allImpact.all : genderImpact[key]; + }; + // Each group's change; none for a group with no one in deep poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = rateOf(label); + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,48 +99,27 @@ export default function DeepPovertyImpactByGenderSubPage({ const obj = `the percentage of ${ x === 'All' ? 'people' : genderMap[x.toLowerCase()] } in deep poverty`; - const key = labelToKey[x]; - const baseline = - x === 'All' ? allImpact.all.baseline : genderImpact[key as 'male' | 'female'].baseline; - const reform = - x === 'All' ? allImpact.all.reform : genderImpact[key as 'male' | 'female'].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = rateOf(x); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the deep poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the deep poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -144,6 +132,7 @@ export default function DeepPovertyImpactByGenderSubPage({ { width: plotWidth, height: plotHeight } ); const { domain: yDomain, ticks: yTicks } = layout; + // Every bar keeps its label on a narrow screen, wrapped to its slot. // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +157,7 @@ export default function DeepPovertyImpactByGenderSubPage({ {probe} - + = { + 'Gini index': 'Gini', + 'Top 10% share': 'Top 10%', + 'Top 1% share': 'Top 1%', +}; interface Props { output: SocietyWideReportOutput; @@ -65,14 +76,19 @@ export default function InequalityImpactSubPage({ const top1Impact = output.inequality.top_1_pct_share; const labels = ['Gini index', 'Top 10% share', 'Top 1% share']; - const metricChanges = [ - giniImpact.reform / giniImpact.baseline - 1, - top10Impact.reform / top10Impact.baseline - 1, - top1Impact.reform / top1Impact.baseline - 1, - ]; + // Each measure's change; none for a measure that is zero before. + const metricChanges = [giniImpact, top10Impact, top1Impact].map(({ baseline, reform }) => + relativeChange(reform, baseline) + ); // Calculate precision for display - const yvaluePrecision = Math.max(1, precision(metricChanges, 100)); + const yvaluePrecision = Math.max( + 1, + precision( + metricChanges.filter((change): change is number => change !== null), + 100 + ) + ); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -103,22 +119,16 @@ export default function InequalityImpactSubPage({ formatter = formatPer; } - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter, - }); + return rateChangeMessage(obj, baseline, reform, countryId, formatter); }; // Generate chart title const getChartTitle = () => { - const signTerm = - metricChanges[0] > 0 && metricChanges[1] > 0 && metricChanges[2] > 0 - ? 'increase' - : metricChanges[0] < 0 && metricChanges[1] < 0 && metricChanges[2] < 0 - ? 'decrease' - : 'have an ambiguous effect on'; + const signTerm = metricChanges.every((change) => change !== null && change > 0) + ? 'increase' + : metricChanges.every((change) => change !== null && change < 0) + ? 'decrease' + : 'have an ambiguous effect on'; const region = regionName(metadata); const regionPhrase = region ? ` in ${region}` : ''; return `This reform would ${signTerm} income inequality${regionPhrase}`; @@ -127,8 +137,7 @@ export default function InequalityImpactSubPage({ // Recharts data const chartData = labels.map((label, i) => ({ name: label, - value: metricChanges[i], - label: (metricChanges[i] > 0 ? '+' : '') + formatPer(metricChanges[i]), + ...relativeBar(metricChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -141,6 +150,7 @@ export default function InequalityImpactSubPage({ { width: plotWidth, height: plotHeight } ); const { domain: yDomain, ticks: yTicks } = layout; + // Every bar keeps its label on a narrow screen, wrapped to its slot. // Enough decimals to write every tick exactly. const tickDigits = tickDecimals(yTicks.map((v) => v * 100)); @@ -155,7 +165,10 @@ export default function InequalityImpactSubPage({ {probe} - + = { 'Working-age adults': 'Adults' }; interface Props { output: SocietyWideReportOutput; @@ -61,18 +69,6 @@ export default function PovertyImpactByAgeSubPage({ // Extract data const povertyImpact = output.poverty.poverty; - // Calculate changes for each age group - const childPovertyChange = povertyImpact.child.reform / povertyImpact.child.baseline - 1; - const adultPovertyChange = povertyImpact.adult.reform / povertyImpact.adult.baseline - 1; - const seniorPovertyChange = povertyImpact.senior.reform / povertyImpact.senior.baseline - 1; - const totalPovertyChange = povertyImpact.all.reform / povertyImpact.all.baseline - 1; - - const povertyChanges = [ - childPovertyChange, - adultPovertyChange, - seniorPovertyChange, - totalPovertyChange, - ]; const povertyLabels = ['Children', 'Working-age adults', 'Seniors', 'All']; const labelToKey: Record = { Children: 'child', @@ -80,6 +76,11 @@ export default function PovertyImpactByAgeSubPage({ Seniors: 'senior', All: 'all', }; + // Each age group's change; none for a group with no one in poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = povertyImpact[labelToKey[label]]; + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,45 +91,27 @@ export default function PovertyImpactByAgeSubPage({ // Generate hover message const hoverMessage = (x: string) => { const obj = `the percentage of ${x === 'All' ? 'people' : x.toLowerCase()} in poverty`; - const baseline = povertyImpact[labelToKey[x]].baseline; - const reform = povertyImpact[labelToKey[x]].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = povertyImpact[labelToKey[x]]; + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = povertyImpact.all.baseline; - const reform = povertyImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = povertyImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -167,9 +150,10 @@ export default function PovertyImpactByAgeSubPage({ (compact && value === 'Working-age adults' ? 'Adults' : value)} + {...categoryAxisProps(povertyLabels, plotWidth, { + shortNames: AGE_SHORT_NAMES, + preferShort: compact, + })} /> = { Male: 'male', Female: 'female', All: 'all', }; + const rateOf = (label: string) => { + const key = labelToKey[label]; + return key === 'all' ? allImpact.all : genderImpact[key]; + }; + // Each group's change; none for a group with no one in poverty before. + const povertyChanges = povertyLabels.map((label) => { + const { baseline, reform } = rateOf(label); + return relativeChange(reform, baseline); + }); const formatPer = (n: number) => formatPercent(n, countryId, { @@ -90,48 +99,27 @@ export default function PovertyImpactByGenderSubPage({ const obj = `the percentage of ${ x === 'All' ? 'people' : genderMap[x.toLowerCase()] } in poverty`; - const key = labelToKey[x]; - const baseline = - x === 'All' ? allImpact.all.baseline : genderImpact[key as 'male' | 'female'].baseline; - const reform = - x === 'All' ? allImpact.all.reform : genderImpact[key as 'male' | 'female'].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + const { baseline, reform } = rateOf(x); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -144,6 +132,7 @@ export default function PovertyImpactByGenderSubPage({ { width: plotWidth, height: plotHeight } ); const { domain: yDomain, ticks: yTicks } = layout; + // Every bar keeps its label on a narrow screen, wrapped to its slot. // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -168,7 +157,7 @@ export default function PovertyImpactByGenderSubPage({ {probe} - + key !== 'all'); - const raceChanges = raceKeys.map((key) => { - const data = raceImpact[key]; - return data.reform / data.baseline - 1; - }); - const totalPovertyChange = allImpact.all.reform / allImpact.all.baseline - 1; + // Each group's change; none for a group with no one in poverty before. + const raceChanges = raceKeys.map((key) => + relativeChange(raceImpact[key].reform, raceImpact[key].baseline) + ); + const totalPovertyChange = relativeChange(allImpact.all.reform, allImpact.all.baseline); const povertyChanges = [...raceChanges, totalPovertyChange]; const povertyLabels = [ @@ -92,43 +98,26 @@ export default function PovertyImpactByRaceSubPage({ const key = labelToKey[x]; const baseline = x === 'All' ? allImpact.all.baseline : raceImpact[key].baseline; const reform = x === 'All' ? allImpact.all.reform : raceImpact[key].reform; - const change = reform / baseline - 1; - return relativeChangeMessage('This reform', obj, change, 0.001, countryId, { - baseline, - reform, - formatter: formatPer, - }); + return rateChangeMessage(obj, baseline, reform, countryId, formatPer); }; // Generate chart title const getChartTitle = () => { - const baseline = allImpact.all.baseline; - const reform = allImpact.all.reform; - const relativeChange = reform / baseline - 1; - const absoluteChange = Math.round(Math.abs(reform - baseline) * 1000) / 10; - const objectTerm = 'the poverty rate'; - const relTerm = formatPercent(Math.abs(relativeChange), countryId, { - maximumFractionDigits: 1, - }); - const absTerm = formatNumber(absoluteChange, countryId, { - maximumFractionDigits: 2, - }); - const term2 = `${relTerm} (${absTerm}pp)`; - const signTerm = relativeChange > 0 ? 'increase' : 'decrease'; const region = regionName(metadata); - const regionPhrase = region ? ` in ${region}` : ''; - - if (absTerm === '0') { - return `This reform would have no effect on ${objectTerm}${regionPhrase}`; - } - return `This reform would ${signTerm} ${objectTerm}${regionPhrase} by ${term2}`; + const { baseline, reform } = allImpact.all; + return rateChangeTitle( + 'the poverty rate', + baseline, + reform, + countryId, + region ? ` in ${region}` : '' + ); }; // Recharts data const chartData = povertyLabels.map((label, i) => ({ name: label, - value: povertyChanges[i], - label: (povertyChanges[i] > 0 ? '+' : '') + formatPer(povertyChanges[i]), + ...relativeBar(povertyChanges[i], formatPer), hoverText: hoverMessage(label), })); @@ -141,6 +130,7 @@ export default function PovertyImpactByRaceSubPage({ { width: plotWidth, height: plotHeight } ); const { domain: yDomain, ticks: yTicks } = layout; + // Every group keeps its label on a narrow screen, wrapped to its slot. // Enough decimals that a 0.25% step does not read as 0.3%. const tickDigits = Math.max(1, tickDecimals(yTicks.map((v) => v * 100))); @@ -165,7 +155,7 @@ export default function PovertyImpactByRaceSubPage({ {probe} - + string; fillColor: (datum: WaterfallDatum) => string; tooltipContent: ReactElement; + /** A shorter name for a bar whose full name does not fit under it. */ + shortNames?: Record; } export default function ReportWaterfallChart({ @@ -62,11 +65,13 @@ export default function ReportWaterfallChart({ formatBarLabel, fillColor, tooltipContent, + shortNames, }: ReportWaterfallChartProps) { - const { plotHeight, probe } = usePlotSize( + const { plotWidth, plotHeight, probe } = usePlotSize( fillHeight ? undefined : fixedPlotHeight(height, MARGIN) ); const { domain, ticks, placements } = getWaterfallLayout(data, plotHeight); + // Every bar keeps its label on a narrow screen, wrapped to its slot. const decimals = tickDecimals(ticks); const tickFormatter = (value: number) => formatTick(value, decimals); const yAxisTitle = fitAxisTitle(yAxisTitles, plotHeight); @@ -128,7 +133,11 @@ export default function ReportWaterfallChart({ datum.name), + plotWidth, + { shortNames } + )} tickLine={false} axisLine={{ stroke: colors.gray[200] }} /> diff --git a/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx b/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx index 390909797..3fd55bc64 100644 --- a/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx +++ b/app/src/components/flagship/report/charts/WinnersLosersIncomeDecileSubPage.tsx @@ -4,6 +4,7 @@ import type { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import { ChartContainer } from '@/components/ChartContainer'; import { TOOLTIP_STYLE } from '@/components/charts'; import ChartExplanation from '@/components/flagship/report/ChartExplanation'; +import { measureTextWidth } from '@/components/flagship/report/charts/barLayout'; import { Stack, Text } from '@/components/ui'; import { colors } from '@/designTokens/colors'; import { spacing } from '@/designTokens/spacing'; @@ -14,7 +15,7 @@ import { getWinnersLosersCsvRows, } from '@/pages/report-output/distributional-impact/distributionalChartUtils'; import type { RootState } from '@/store'; -import { RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; +import { CHART_EXPORT_KEEP, RECHARTS_FONT_STYLE } from '@/utils/chartUtils'; import { formatPercent } from '@/utils/formatters'; import { regionName } from '@/utils/impactChartUtils'; @@ -42,6 +43,12 @@ const LEGEND_TEXT_MAP: Record = { }; const BAR_SIZE = 18; +/** The legend: a 12px swatch, its name 8px on, rows 8px apart. */ +const LEGEND_SWATCH = 12; +const LEGEND_TEXT_GAP = 8; +const LEGEND_ROW_GAP = 8; +const LEGEND_ROW = LEGEND_SWATCH + LEGEND_ROW_GAP; +const LEGEND_FONT_SIZE = 12; const TOOLTIP_POSITION = { x: 72, y: 0 }; const TOOLTIP_WRAPPER_STYLE = { zIndex: 1000, @@ -49,11 +56,12 @@ const TOOLTIP_WRAPPER_STYLE = { maxWidth: 'min(280px, calc(100vw - 32px))', }; -function WinnersLosersTooltip({ active, payload, label }: any) { +/** A row's outcome shares on hover. The shares count people, so "All" is everyone. */ +export function WinnersLosersTooltip({ active, payload, label }: any) { if (!active || !payload?.length) { return null; } - const decileLabel = label === 'All' ? 'All households' : `Decile ${label}`; + const decileLabel = label === 'All' ? 'All people' : `Decile ${label}`; return (

{decileLabel}

@@ -240,41 +248,48 @@ export default function WinnersLosersIncomeDecileSubPage({ ); + // Drawn in SVG, so the chart's download keeps the key to its colors. A + // few pixels spare keep a name whole if the font loads wider than measured. + const legendWidth = + LEGEND_SWATCH + + LEGEND_TEXT_GAP + + 4 + + Math.ceil( + Math.max(...CATEGORIES.map((cat) => measureTextWidth(LEGEND_TEXT_MAP[cat], LEGEND_FONT_SIZE))) + ); const legend = (
- {CATEGORIES.map((cat) => ( -
- - - {LEGEND_TEXT_MAP[cat]} - -
- ))} + + {CATEGORIES.map((cat, index) => ( + + + + {LEGEND_TEXT_MAP[cat]} + + + ))} +
); @@ -283,7 +298,10 @@ export default function WinnersLosersIncomeDecileSubPage({
-
{allBarChart}
+ {/* Kept by the download at its short compact height. */} +
+ {allBarChart} +
{decileBarChart}
@@ -306,7 +324,9 @@ export default function WinnersLosersIncomeDecileSubPage({
-
{allBarChart}
+
+ {allBarChart} +
{decileBarChart}
diff --git a/app/src/components/flagship/report/charts/barLayout.ts b/app/src/components/flagship/report/charts/barLayout.ts index 68d6f7763..a27159532 100644 --- a/app/src/components/flagship/report/charts/barLayout.ts +++ b/app/src/components/flagship/report/charts/barLayout.ts @@ -447,3 +447,76 @@ export function fitAxisTitle( ): string | undefined { return candidates.find((title) => measure(title, VALUE_LABEL_FONT_SIZE) <= plotHeight); } + +// --------------------------------------------------------------------------- +// Category axis +// --------------------------------------------------------------------------- + +/** Clear space kept between two category labels side by side, px. */ +const CATEGORY_LABEL_SIDE_GAP = 4; +/** From the x-axis line to a category label's first line: Recharts' tick size and margin, px. */ +const CATEGORY_LABEL_OFFSET = 8; +/** Room under a category label's last line for its descenders, px. */ +const CATEGORY_LABEL_DESCENT = 2; + +/** The lines a label wraps to, by word, in `width` px. A word wider than that keeps its own line. */ +export function wrapLabel( + label: string, + width: number, + fontSize: number, + measure: MeasureText = measureTextWidth +): string[] { + const lines: string[] = []; + for (const word of label.split(/\s+/).filter(Boolean)) { + const last = lines[lines.length - 1]; + if (last !== undefined && measure(`${last} ${word}`, fontSize) <= width) { + lines[lines.length - 1] = `${last} ${word}`; + } else { + lines.push(word); + } + } + return lines; +} + +export interface CategoryAxis