diff --git a/public/data/winners_losers.csv b/public/data/winners_losers.csv deleted file mode 100644 index 5a09cca..0000000 --- a/public/data/winners_losers.csv +++ /dev/null @@ -1,121 +0,0 @@ -reform_id,reform_name,year,metric,value -combined,All policies combined,2026,winners_pct,76.30010420914206 -combined,All policies combined,2026,losers_pct,0.26886443120694936 -combined,All policies combined,2026,unchanged_pct,23.431031359650987 -combined,All policies combined,2027,winners_pct,76.85047709409855 -combined,All policies combined,2027,losers_pct,0.2688644301169465 -combined,All policies combined,2027,unchanged_pct,22.8806584757845 -combined,All policies combined,2028,winners_pct,76.87932883738758 -combined,All policies combined,2028,losers_pct,0.3086249405121823 -combined,All policies combined,2028,unchanged_pct,22.81204622210024 -combined,All policies combined,2029,winners_pct,76.9278429686158 -combined,All policies combined,2029,losers_pct,0.308603580607364 -combined,All policies combined,2029,unchanged_pct,22.763553450776822 -combined,All policies combined,2030,winners_pct,76.97013076092061 -combined,All policies combined,2030,losers_pct,0.3086035796096772 -combined,All policies combined,2030,unchanged_pct,22.721265659469715 -scp_inflation,SCP inflation adjustment (£28.20/week),2026,winners_pct,8.473852156634988 -scp_inflation,SCP inflation adjustment (£28.20/week),2026,losers_pct,0.0 -scp_inflation,SCP inflation adjustment (£28.20/week),2026,unchanged_pct,91.526147843365 -scp_inflation,SCP inflation adjustment (£28.20/week),2027,winners_pct,8.267078246734753 -scp_inflation,SCP inflation adjustment (£28.20/week),2027,losers_pct,0.0 -scp_inflation,SCP inflation adjustment (£28.20/week),2027,unchanged_pct,91.73292175326525 -scp_inflation,SCP inflation adjustment (£28.20/week),2028,winners_pct,8.267078259456149 -scp_inflation,SCP inflation adjustment (£28.20/week),2028,losers_pct,0.0 -scp_inflation,SCP inflation adjustment (£28.20/week),2028,unchanged_pct,91.73292174054384 -scp_inflation,SCP inflation adjustment (£28.20/week),2029,winners_pct,8.267078222720755 -scp_inflation,SCP inflation adjustment (£28.20/week),2029,losers_pct,0.0 -scp_inflation,SCP inflation adjustment (£28.20/week),2029,unchanged_pct,91.73292177727926 -scp_inflation,SCP inflation adjustment (£28.20/week),2030,winners_pct,8.26707814901249 -scp_inflation,SCP inflation adjustment (£28.20/week),2030,losers_pct,0.0 -scp_inflation,SCP inflation adjustment (£28.20/week),2030,unchanged_pct,91.73292185098751 -scp_baby_boost,SCP Premium for under-ones (£40/week),2026,winners_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2026,losers_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2026,unchanged_pct,100.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2027,winners_pct,0.551153027092878 -scp_baby_boost,SCP Premium for under-ones (£40/week),2027,losers_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2027,unchanged_pct,99.44884697290712 -scp_baby_boost,SCP Premium for under-ones (£40/week),2028,winners_pct,0.5511530316958071 -scp_baby_boost,SCP Premium for under-ones (£40/week),2028,losers_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2028,unchanged_pct,99.4488469683042 -scp_baby_boost,SCP Premium for under-ones (£40/week),2029,winners_pct,0.5511530293458775 -scp_baby_boost,SCP Premium for under-ones (£40/week),2029,losers_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2029,unchanged_pct,99.44884697065413 -scp_baby_boost,SCP Premium for under-ones (£40/week),2030,winners_pct,0.5511530291709076 -scp_baby_boost,SCP Premium for under-ones (£40/week),2030,losers_pct,0.0 -scp_baby_boost,SCP Premium for under-ones (£40/week),2030,unchanged_pct,99.4488469708291 -income_tax_basic_uplift,Basic rate threshold +7.4%,2026,winners_pct,72.08099149553277 -income_tax_basic_uplift,Basic rate threshold +7.4%,2026,losers_pct,0.26886443120694936 -income_tax_basic_uplift,Basic rate threshold +7.4%,2026,unchanged_pct,27.650144073260286 -income_tax_basic_uplift,Basic rate threshold +7.4%,2027,winners_pct,72.63141567467167 -income_tax_basic_uplift,Basic rate threshold +7.4%,2027,losers_pct,0.2688644301169465 -income_tax_basic_uplift,Basic rate threshold +7.4%,2027,unchanged_pct,27.099719895211376 -income_tax_basic_uplift,Basic rate threshold +7.4%,2028,winners_pct,72.6602887537907 -income_tax_basic_uplift,Basic rate threshold +7.4%,2028,losers_pct,0.30860358508101104 -income_tax_basic_uplift,Basic rate threshold +7.4%,2028,unchanged_pct,27.031107661128296 -income_tax_basic_uplift,Basic rate threshold +7.4%,2029,winners_pct,72.70878153097307 -income_tax_basic_uplift,Basic rate threshold +7.4%,2029,losers_pct,0.308603580607364 -income_tax_basic_uplift,Basic rate threshold +7.4%,2029,unchanged_pct,26.98261488841957 -income_tax_basic_uplift,Basic rate threshold +7.4%,2030,winners_pct,72.75106937695736 -income_tax_basic_uplift,Basic rate threshold +7.4%,2030,losers_pct,0.3086035796096772 -income_tax_basic_uplift,Basic rate threshold +7.4%,2030,unchanged_pct,26.940327043432955 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2026,winners_pct,51.0402029098354 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2026,losers_pct,0.0 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2026,unchanged_pct,48.95979709016461 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2027,winners_pct,52.66510427623461 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2027,losers_pct,0.0 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2027,unchanged_pct,47.33489572376539 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2028,winners_pct,52.96722429647331 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2028,losers_pct,0.0 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2028,unchanged_pct,47.03277570352669 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2029,winners_pct,53.406298602890566 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2029,losers_pct,0.0 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2029,unchanged_pct,46.593701397109434 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2030,winners_pct,53.5602445413499 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2030,losers_pct,0.0 -income_tax_intermediate_uplift,Intermediate rate threshold +7.4%,2030,unchanged_pct,46.439755458650104 -higher_rate_freeze,Higher rate threshold freeze,2026,winners_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2026,losers_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2026,unchanged_pct,100.0 -higher_rate_freeze,Higher rate threshold freeze,2027,winners_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2027,losers_pct,25.489676458366045 -higher_rate_freeze,Higher rate threshold freeze,2027,unchanged_pct,74.51032354163397 -higher_rate_freeze,Higher rate threshold freeze,2028,winners_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2028,losers_pct,26.301560952717068 -higher_rate_freeze,Higher rate threshold freeze,2028,unchanged_pct,73.69843904728293 -higher_rate_freeze,Higher rate threshold freeze,2029,winners_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2029,losers_pct,26.724724913173453 -higher_rate_freeze,Higher rate threshold freeze,2029,unchanged_pct,73.27527508682655 -higher_rate_freeze,Higher rate threshold freeze,2030,winners_pct,0.0 -higher_rate_freeze,Higher rate threshold freeze,2030,losers_pct,26.947933720751593 -higher_rate_freeze,Higher rate threshold freeze,2030,unchanged_pct,73.05206627924841 -advanced_rate_freeze,Advanced rate threshold freeze,2026,winners_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2026,losers_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2026,unchanged_pct,100.0 -advanced_rate_freeze,Advanced rate threshold freeze,2027,winners_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2027,losers_pct,8.082662985261335 -advanced_rate_freeze,Advanced rate threshold freeze,2027,unchanged_pct,91.91733701473866 -advanced_rate_freeze,Advanced rate threshold freeze,2028,winners_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2028,losers_pct,8.261146379963936 -advanced_rate_freeze,Advanced rate threshold freeze,2028,unchanged_pct,91.73885362003605 -advanced_rate_freeze,Advanced rate threshold freeze,2029,winners_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2029,losers_pct,8.33345086090616 -advanced_rate_freeze,Advanced rate threshold freeze,2029,unchanged_pct,91.66654913909385 -advanced_rate_freeze,Advanced rate threshold freeze,2030,winners_pct,0.0 -advanced_rate_freeze,Advanced rate threshold freeze,2030,losers_pct,8.501212515741937 -advanced_rate_freeze,Advanced rate threshold freeze,2030,unchanged_pct,91.49878748425806 -top_rate_freeze,Top rate threshold freeze,2026,winners_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2026,losers_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2026,unchanged_pct,100.0 -top_rate_freeze,Top rate threshold freeze,2027,winners_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2027,losers_pct,1.055528658238143 -top_rate_freeze,Top rate threshold freeze,2027,unchanged_pct,98.94447134176185 -top_rate_freeze,Top rate threshold freeze,2028,winners_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2028,losers_pct,1.2522646192445515 -top_rate_freeze,Top rate threshold freeze,2028,unchanged_pct,98.74773538075546 -top_rate_freeze,Top rate threshold freeze,2029,winners_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2029,losers_pct,1.3053000764830598 -top_rate_freeze,Top rate threshold freeze,2029,unchanged_pct,98.69469992351694 -top_rate_freeze,Top rate threshold freeze,2030,winners_pct,0.0 -top_rate_freeze,Top rate threshold freeze,2030,losers_pct,1.3612099274118437 -top_rate_freeze,Top rate threshold freeze,2030,unchanged_pct,98.63879007258815 diff --git a/src/components/BudgetBarChart.jsx b/src/components/BudgetBarChart.jsx index 2771cf4..b895a7b 100644 --- a/src/components/BudgetBarChart.jsx +++ b/src/components/BudgetBarChart.jsx @@ -81,8 +81,8 @@ export default function BudgetBarChart({ data, title, description, stacked = fal // Check if we should show net impact line (only when multiple policies have data) const showNetImpact = stacked && activePolicies.length > 1; - // Calculate Y-axis domain with padding (only for active/selected policies) - const calculateYDomain = () => { + // Calculate symmetric Y-axis domain with round number increments + const calculateYAxisConfig = () => { let minVal = 0, maxVal = 0; data.forEach(d => { let negSum = 0, posSum = 0; @@ -94,11 +94,35 @@ export default function BudgetBarChart({ data, title, description, stacked = fal minVal = Math.min(minVal, negSum); maxVal = Math.max(maxVal, posSum); }); - // Add 15% padding - const padding = Math.max(Math.abs(minVal), Math.abs(maxVal)) * 0.15; - return [Math.floor((minVal - padding) / 20) * 20, Math.ceil((maxVal + padding) / 20) * 20]; + + // Find the max absolute value + const maxAbs = Math.max(Math.abs(minVal), Math.abs(maxVal)); + + // Choose a nice round interval based on data range + let interval; + if (maxAbs <= 100) interval = 50; + else if (maxAbs <= 200) interval = 50; + else if (maxAbs <= 400) interval = 100; + else interval = 150; + + // Round up to nice number for symmetric axis + const roundedMax = Math.ceil((maxAbs * 1.1) / interval) * interval || interval; + + // Generate ticks from -roundedMax to +roundedMax, always including 0 + const ticks = []; + for (let i = -roundedMax; i <= roundedMax; i += interval) { + ticks.push(i); + } + + return { + domain: [-roundedMax, roundedMax], + ticks: ticks + }; }; - const yDomain = stacked ? calculateYDomain() : ['auto', 'auto']; + + const yAxisConfig = stacked ? calculateYAxisConfig() : { domain: ['auto', 'auto'], ticks: undefined }; + const yDomain = yAxisConfig.domain; + const yTicks = yAxisConfig.ticks; return (
@@ -119,13 +143,14 @@ export default function BudgetBarChart({ data, title, description, stacked = fal /> [ formatValue(value), - name === "netImpact" ? "Net impact" : name + name === "Net impact" ? "Net impact" : name ]} labelFormatter={formatYear} /> @@ -171,7 +196,7 @@ export default function BudgetBarChart({ data, title, description, stacked = fal stroke="#000000" strokeWidth={2} dot={{ fill: "#000000", stroke: "#000000", strokeWidth: 1, r: 4 }} - name="netImpact" + name="Net impact" label={} /> )} diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index cbed1dd..65d195a 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -6,15 +6,117 @@ import LocalAreaSection from "./LocalAreaSection"; import SFCComparisonTable from "./SFCComparisonTable"; import MansionTaxMap from "./MansionTaxMap"; import "./Dashboard.css"; -import { POLICY_NAMES, ALL_POLICY_IDS } from "../utils/policyConfig"; +import { POLICY_NAMES, ALL_POLICY_IDS, REVENUE_POLICIES } from "../utils/policyConfig"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; + +// Small chart component for threshold comparisons +const ThresholdChart = ({ data, baselineLabel = "Baseline", reformLabel = "Reform" }) => ( +
+ + + + + `£${(v / 1000).toFixed(0)}k`} + tick={{ fontSize: 10 }} + tickLine={false} + axisLine={false} + domain={['auto', 'auto']} + width={45} + /> + [`£${value.toLocaleString()}`, name]} + labelFormatter={(label) => label} + contentStyle={{ fontSize: "12px", borderRadius: "6px" }} + /> + + + + + +
+); + +// Data for threshold charts +const BASIC_RATE_THRESHOLD_DATA = [ + { year: "2025-26", baseline: 15398, reform: 15398 }, + { year: "2026-27", baseline: 15706, reform: 16538 }, + { year: "2027-28", baseline: 16020, reform: 16872 }, + { year: "2028-29", baseline: 16341, reform: 17216 }, + { year: "2029-30", baseline: 16667, reform: 17567 }, + { year: "2030-31", baseline: 17001, reform: 17918 }, +]; + +const INTERMEDIATE_RATE_THRESHOLD_DATA = [ + { year: "2025-26", baseline: 27492, reform: 27492 }, + { year: "2026-27", baseline: 28042, reform: 29527 }, + { year: "2027-28", baseline: 28603, reform: 30123 }, + { year: "2028-29", baseline: 29175, reform: 30738 }, + { year: "2029-30", baseline: 29758, reform: 31365 }, + { year: "2030-31", baseline: 30354, reform: 31992 }, +]; + +const HIGHER_RATE_THRESHOLD_DATA = [ + { year: "2025-26", baseline: 43662, reform: 43662 }, + { year: "2026-27", baseline: 43662, reform: 43662 }, + { year: "2027-28", baseline: 44544, reform: 43662 }, + { year: "2028-29", baseline: 45453, reform: 43662 }, + { year: "2029-30", baseline: 46380, reform: 44553 }, + { year: "2030-31", baseline: 47308, reform: 45444 }, +]; + +const ADVANCED_RATE_THRESHOLD_DATA = [ + { year: "2025-26", baseline: 75000, reform: 75000 }, + { year: "2026-27", baseline: 75000, reform: 75000 }, + { year: "2027-28", baseline: 76515, reform: 75000 }, + { year: "2028-29", baseline: 78076, reform: 75000 }, + { year: "2029-30", baseline: 79669, reform: 76530 }, + { year: "2030-31", baseline: 81262, reform: 78061 }, +]; + +const TOP_RATE_THRESHOLD_DATA = [ + { year: "2025-26", baseline: 125140, reform: 125140 }, + { year: "2026-27", baseline: 125140, reform: 125140 }, + { year: "2027-28", baseline: 127668, reform: 125140 }, + { year: "2028-29", baseline: 130273, reform: 125140 }, + { year: "2029-30", baseline: 132930, reform: 127693 }, + { year: "2030-31", baseline: 135589, reform: 130247 }, +]; // Section definitions for navigation const SECTIONS = [ { id: "introduction", label: "Introduction" }, - { id: "budgetary-impact", label: "Budgetary impact" }, - { id: "living-standards", label: "Living standards" }, - { id: "poverty", label: "Poverty rate" }, - { id: "local-authorities", label: "Impact by local authority" }, + { id: "income-tax-benefits", label: "Income tax and benefits" }, { id: "mansion-tax", label: "Mansion tax" }, ]; @@ -114,24 +216,8 @@ const POLICY_INFO = { the 20% basic rate instead of the 19% starter rate.
View basic rate threshold by year - - - - - - - - - - - - - - - - -
YearBasic rate starts atChange from prior year
2025-26£15,398
2026-27£16,538+7.4%
2027-28£16,872CPI
2028-29£17,216CPI
2029-30£17,567CPI
2030-31£17,918CPI
-

Note: From 2027-28, thresholds projected using OBR CPI forecasts (~2% annually). Source: Scottish Government | OBR EFO November 2025

+ +

Note: Baseline shows CPI-only growth . Reform shows 7.4% uplift in 2026-27, then CPI growth. Source: Scottish Government | OBR EFO November 2025

), @@ -146,24 +232,8 @@ const POLICY_INFO = { paying the 21% intermediate rate instead of the 20% basic rate.
View intermediate rate threshold by year - - - - - - - - - - - - - - - - -
YearIntermediate rate starts atChange from prior year
2025-26£27,492
2026-27£29,527+7.4%
2027-28£30,123CPI
2028-29£30,738CPI
2029-30£31,365CPI
2030-31£31,992CPI
-

Note: From 2027-28, thresholds projected using OBR CPI forecasts (~2% annually). Source: Scottish Government | OBR EFO November 2025

+ +

Note: Baseline shows CPI-only growth . Reform shows 7.4% uplift in 2026-27, then CPI growth. Source: Scottish Government | OBR EFO November 2025

), @@ -174,28 +244,12 @@ const POLICY_INFO = { explanation: (
  • Higher rate threshold freeze: The higher rate (42%) threshold remains frozen - at £43,662 until 2028-29. Without the freeze, this threshold would increase with inflation, - meaning fewer taxpayers would pay the higher rate. The freeze raises revenue for the Scottish Government. + at £43,662 from 2025-26 through 2028-29, then resumes CPI uprating (£44,553 in 2029-30, £45,444 in 2030-31). + Without the freeze, the threshold would reach ~£47k by 2030-31. The freeze raises revenue by bringing more taxpayers into the higher rate band.
    View higher rate threshold by year - - - - - - - - - - - - - - - - -
    YearHigher rate (42%) starts atStatus
    2025-26£43,662Frozen
    2026-27£43,662Frozen
    2027-28£43,662Frozen
    2028-29£43,662Frozen
    2029-30*£44,553CPI
    2030-31*£45,444CPI
    -

    Note: Freeze confirmed until 2028-29. *2029-30 onwards: PolicyEngine assumption (CPI uprating). Source: Scottish Budget Chapter 2 | SFC January 2026

    + +

    Note: Baseline assumes CPI growth after 2026-27. Freeze confirmed until 2028-29; 2029-30 onwards assumes CPI uprating. Source: Scottish Budget Chapter 2 | SFC January 2026

  • ), @@ -206,28 +260,12 @@ const POLICY_INFO = { explanation: (
  • Advanced rate threshold freeze: The advanced rate (45%) threshold remains frozen - at £75,000 until 2028-29. Without the freeze, this threshold would increase with inflation. - The freeze raises revenue from higher earners. + at £75,000 from 2025-26 through 2028-29, then resumes CPI uprating (£76,530 in 2029-30, £78,061 in 2030-31). + Without the freeze, the threshold would reach ~£81k by 2030-31. The freeze raises revenue from higher earners.
    View advanced rate threshold by year - - - - - - - - - - - - - - - - -
    YearAdvanced rate (45%) starts atStatus
    2025-26£75,000Frozen
    2026-27£75,000Frozen
    2027-28£75,000Frozen
    2028-29£75,000Frozen
    2029-30*£76,530CPI
    2030-31*£78,061CPI
    -

    Note: Freeze confirmed until 2028-29. *2029-30 onwards: PolicyEngine assumption (CPI uprating). Source: Scottish Budget Chapter 2

    + +

    Note: Baseline assumes CPI growth after 2026-27. Freeze confirmed until 2028-29; 2029-30 onwards assumes CPI uprating. Source: Scottish Budget Chapter 2 | SFC January 2026

  • ), @@ -238,28 +276,12 @@ const POLICY_INFO = { explanation: (
  • Top rate threshold freeze: The top rate (48%) threshold remains frozen - at £125,140 until 2028-29. Without the freeze, this threshold would increase with inflation. - The freeze raises revenue from the highest earners. + at £125,140 from 2025-26 through 2028-29, then resumes CPI uprating (£127,693 in 2029-30, £130,247 in 2030-31). + Without the freeze, the threshold would reach ~£136k by 2030-31. The freeze raises revenue from the highest earners.
    View top rate threshold by year - - - - - - - - - - - - - - - - -
    YearTop rate (48%) starts atStatus
    2025-26£125,140Frozen
    2026-27£125,140Frozen
    2027-28£125,140Frozen
    2028-29£125,140Frozen
    2029-30*£127,693CPI
    2030-31*£130,247CPI
    -

    Note: Freeze confirmed until 2028-29. *2029-30 onwards: PolicyEngine assumption (CPI uprating). £125,140 aligns with UK Personal Allowance taper. Source: Scottish Budget Chapter 2

    + +

    Note: Baseline assumes CPI growth after 2026-27. Freeze confirmed until 2028-29; 2029-30 onwards assumes CPI uprating. £125,140 aligns with UK Personal Allowance taper. Source: Scottish Budget Chapter 2 | SFC January 2026

  • ), @@ -286,7 +308,7 @@ export default function Dashboard({ selectedPolicies = [] }) { const [rawBudgetaryData, setRawBudgetaryData] = useState([]); const [rawDistributionalData, setRawDistributionalData] = useState([]); const [activeSection, setActiveSection] = useState("introduction"); - const [selectedYear, setSelectedYear] = useState(2026); + const [selectedYear, setSelectedYear] = useState(2028); const AVAILABLE_YEARS = [2026, 2027, 2028, 2029, 2030]; @@ -464,6 +486,56 @@ export default function Dashboard({ selectedPolicies = [] }) { }); }, [isStacked, rawDistributionalData, selectedYear, selectedPolicies]); + // Calculate decile chart y-axis domain across ALL years for consistent axis + const decileYAxisDomain = useMemo(() => { + if (!isStacked || rawDistributionalData.length === 0) return null; + + const deciles = ["1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th"]; + let maxAbsRelative = 0; + let maxAbsAbsolute = 0; + + // Check all years to find max values + AVAILABLE_YEARS.forEach(year => { + deciles.forEach(decile => { + let posRelative = 0, negRelative = 0; + let posAbsolute = 0, negAbsolute = 0; + + selectedPolicies.forEach(policyId => { + const policyName = POLICY_NAMES[policyId]; + const row = rawDistributionalData.find( + r => r.reform_id === policyId && r.year === String(year) && r.decile === decile + ); + const relValue = row ? parseFloat(row.value) || 0 : 0; + const absValue = row ? parseFloat(row.absolute_change) || 0 : 0; + + // Check if this is a revenue policy (values should be negative) + const isRevenue = REVENUE_POLICIES.includes(policyId); + const adjustedRel = isRevenue ? -Math.abs(relValue) : relValue; + const adjustedAbs = isRevenue ? -Math.abs(absValue) : absValue; + + if (adjustedRel > 0) posRelative += adjustedRel; + else negRelative += adjustedRel; + if (adjustedAbs > 0) posAbsolute += adjustedAbs; + else negAbsolute += adjustedAbs; + }); + + maxAbsRelative = Math.max(maxAbsRelative, Math.abs(posRelative), Math.abs(negRelative)); + maxAbsAbsolute = Math.max(maxAbsAbsolute, Math.abs(posAbsolute), Math.abs(negAbsolute)); + }); + }); + + // Round up to nice numbers + const relInterval = maxAbsRelative <= 1 ? 0.5 : maxAbsRelative <= 3 ? 1 : 2; + const absInterval = maxAbsAbsolute <= 50 ? 10 : maxAbsAbsolute <= 100 ? 20 : 50; + const roundedRelative = Math.ceil((maxAbsRelative * 1.1) / relInterval) * relInterval || 1; + const roundedAbsolute = Math.ceil((maxAbsAbsolute * 1.1) / absInterval) * absInterval || 40; + + return { + relative: [-roundedRelative, roundedRelative], + absolute: [-roundedAbsolute, roundedAbsolute], + }; + }, [isStacked, rawDistributionalData, selectedPolicies]); + // Get decile data filtered by selected year - aggregate selected policies const decileDataForYear = useMemo(() => { if (rawDistributionalData.length === 0) return []; @@ -541,37 +613,42 @@ export default function Dashboard({ selectedPolicies = [] }) { {" "}on high-value properties from April 2028, detailed in the last section.

    -

    - The Budget includes the following measures: -

    -
      - {isStacked ? ( - <> - {POLICY_INFO.income_tax_basic_uplift.explanation} - {POLICY_INFO.income_tax_intermediate_uplift.explanation} - {POLICY_INFO.scp_inflation.explanation} - {POLICY_INFO.scp_baby_boost.explanation} - {POLICY_INFO.higher_rate_freeze.explanation} - {POLICY_INFO.advanced_rate_freeze.explanation} - {POLICY_INFO.top_rate_freeze.explanation} - - ) : ( - policyInfo.explanation - )} -
    -
    - Methodology -

    - This analysis uses the PolicyEngine microsimulation model, which{" "} - reweights{" "} - the Family Resources Survey to match Scottish demographics. See also:{" "} - pre-budget dashboard{" "} - | poverty methodology. -

    +
    + + Click to see what the Budget includes + +
      + {isStacked ? ( + <> + {POLICY_INFO.income_tax_basic_uplift.explanation} + {POLICY_INFO.income_tax_intermediate_uplift.explanation} + {POLICY_INFO.scp_inflation.explanation} + {POLICY_INFO.scp_baby_boost.explanation} + {POLICY_INFO.higher_rate_freeze.explanation} + {POLICY_INFO.advanced_rate_freeze.explanation} + {POLICY_INFO.top_rate_freeze.explanation} + + ) : ( + policyInfo.explanation + )} +
    +
    + Methodology +

    + This analysis uses the PolicyEngine microsimulation model, which{" "} + reweights{" "} + the Family Resources Survey to match Scottish demographics. See also:{" "} + pre-budget dashboard{" "} + | poverty methodology. +

    +
    + {/* Income Tax and Benefits Section */} +

    (sectionRefs.current["income-tax-benefits"] = el)} style={{ marginTop: "32px" }}>Income tax and benefits

    + {/* Budgetary Impact Section */} -

    (sectionRefs.current["budgetary-impact"] = el)}>Budgetary impact

    +

    (sectionRefs.current["budgetary-impact"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "24px", marginBottom: "12px", padding: "0" }}>Budgetary impact

    This section shows the estimated fiscal cost of the budget measures to the Scottish Government.

    @@ -600,7 +677,7 @@ export default function Dashboard({ selectedPolicies = [] }) { {/* Living Standards Section */} -

    (sectionRefs.current["living-standards"] = el)}>Living standards

    +

    (sectionRefs.current["living-standards"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Living standards

    This section shows how household incomes in Scotland change as a result of the {policyInfo.name} policy.

    @@ -621,11 +698,12 @@ export default function Dashboard({ selectedPolicies = [] }) { onYearChange={setSelectedYear} availableYears={AVAILABLE_YEARS} selectedPolicies={selectedPolicies} + fixedYAxisDomain={decileYAxisDomain} /> ) : null} {/* Poverty Section */} -

    (sectionRefs.current["poverty"] = el)}>Poverty rate

    +

    (sectionRefs.current["poverty"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Poverty rate

    This section shows how poverty rates change under the budget measures. The UK uses four poverty measures: absolute vs relative poverty, each measured before or after housing costs. @@ -643,7 +721,7 @@ export default function Dashboard({ selectedPolicies = [] }) { )} {/* Local Authority Impact Section */} -

    (sectionRefs.current["local-authorities"] = el)}>Impact by local authority

    +

    (sectionRefs.current["local-authorities"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Impact by local authority

    This section shows how the budget measures affect different local authorities across Scotland. Select a local authority to see the estimated impact on households in that area. @@ -657,15 +735,16 @@ export default function Dashboard({ selectedPolicies = [] }) { /> {/* Mansion Tax Section */} -

    (sectionRefs.current["mansion-tax"] = el)}> - - +
    (sectionRefs.current["mansion-tax"] = el)} style={{ marginTop: "32px" }}> + + Mansion tax

    - The Scottish Budget 2026-27 introduced new council tax bands for properties valued at £1 million or more, - effective from April 2028. The Finance Secretary estimated £16m in annual revenue; using UK benchmark rates, we estimate £18.5m. - The map below shows each constituency's share. Edinburgh constituencies account for ~47% of total revenue. + The Scottish Budget 2026-27 introduced two new council tax bands for properties with a 2026 market value above £1 million, + effective from April 2028. The Finance Secretary estimated £16m in annual revenue. + The SFC does not cost this policy as Council Tax is a local tax outside their remit. + Using UK benchmark rates, we estimate £18.5m in annual revenue. The map below shows each constituency's share. Edinburgh constituencies account for ~47% of total revenue.

    How we calculate @@ -679,23 +758,19 @@ export default function Dashboard({ selectedPolicies = [] }) {
    1. - We estimate total revenue by multiplying 11,481 £1m+ properties (from Savills) by the £1,607 average annual rate based on UK benchmark rates. + We estimate total revenue of £18.5m by multiplying 11,481 £1m+ properties in Scotland (from Savills) by a £1,607 average annual rate. This rate is based on the UK's High Value Council Tax Surcharge of £2,500/year for properties over £2m, from which we extrapolate £1,500/year for the Scottish £1-2m band. Using Savills 2024 sales data (89% of sales £1-2m, 11% over £2m), we calculate the weighted average of £1,607/year.
    2. - We use council-level £1m+ sales data from Registers of Scotland to determine geographic distribution across Scotland. + We use council-level £1m+ sales data from Registers of Scotland to allocate revenue geographically across Scotland.
    3. - Within each council, we allocate sales to constituencies based on population weighted by Band H property concentration. + Within each council, we allocate to constituencies based on population weighted by Band H property concentration. Band H threshold (>£212k in 1991) equals ~£1.06m today, closely matching the £1m threshold.
    4. - We use Band H as a proxy because its threshold (>£212k in 1991) equals approximately £1.06m today, closely matching the mansion tax's £1m threshold. -
    -
    - 5. - Each constituency's revenue is calculated by multiplying its share of total sales by the £18.5m total revenue. + Each constituency's revenue is calculated as its share of total sales multiplied by the £18.5m total revenue.
    diff --git a/src/components/DecileChart.jsx b/src/components/DecileChart.jsx index 87c4f95..3498508 100644 --- a/src/components/DecileChart.jsx +++ b/src/components/DecileChart.jsx @@ -66,6 +66,7 @@ export default function DecileChart({ onYearChange = null, availableYears = [2026, 2027, 2028, 2029, 2030], selectedPolicies = [], + fixedYAxisDomain = null, }) { const [viewMode, setViewMode] = useState("absolute"); // "absolute" or "relative" const formatYearRange = (year) => `${year}-${(year + 1).toString().slice(-2)}`; @@ -148,37 +149,64 @@ export default function DecileChart({ // Show net change line when multiple policies are active const showNetChange = stacked && activePolicies.length > 1; - // Calculate y-axis domain with padding - let yMin = 0, yMax = 10; - if (stacked) { + // Calculate symmetric y-axis domain with round number increments + const calculateSymmetricDomain = () => { let minSum = 0, maxSum = 0; - chartData.forEach(d => { - let positiveSum = 0, negativeSum = 0; - activePolicies.forEach(name => { - const val = d[name] || 0; - if (val > 0) positiveSum += val; - else negativeSum += val; + if (stacked) { + chartData.forEach(d => { + let positiveSum = 0, negativeSum = 0; + activePolicies.forEach(name => { + const val = d[name] || 0; + if (val > 0) positiveSum += val; + else negativeSum += val; + }); + minSum = Math.min(minSum, negativeSum); + maxSum = Math.max(maxSum, positiveSum); }); - minSum = Math.min(minSum, negativeSum); - maxSum = Math.max(maxSum, positiveSum); - }); - // Add 15% padding - const padding = Math.max(Math.abs(minSum), Math.abs(maxSum)) * 0.15; + } else { + const values = chartData.map(d => d.value || 0); + maxSum = Math.max(...values, 0); + minSum = Math.min(...values, 0); + } + + // Find the max absolute value and round up to nice number for symmetric axis + const maxAbs = Math.max(Math.abs(minSum), Math.abs(maxSum)); + if (viewMode === "relative") { - yMin = Math.floor((minSum - padding) * 10) / 10; - yMax = Math.ceil((maxSum + padding) * 10) / 10; + // For percentages: use intervals of 0.5, 1, or 2 + const interval = maxAbs <= 1 ? 0.5 : maxAbs <= 3 ? 1 : 2; + const roundedMax = Math.ceil((maxAbs * 1.1) / interval) * interval; + return [-roundedMax, roundedMax]; } else { - yMin = Math.floor((minSum - padding) / 10) * 10; - yMax = Math.ceil((maxSum + padding) / 10) * 10 || 40; + // For absolute £: use intervals of 10, 20, or 50 + const interval = maxAbs <= 50 ? 10 : maxAbs <= 100 ? 20 : 50; + const roundedMax = Math.ceil((maxAbs * 1.1) / interval) * interval || 40; + return [-roundedMax, roundedMax]; } - } else { - const values = chartData.map(d => d.value || 0); - const maxVal = Math.max(...values); - yMin = 0; - yMax = viewMode === "relative" - ? Math.ceil(maxVal * 10) / 10 - : Math.max(40, Math.ceil(maxVal / 10) * 10); - } + }; + + // Generate symmetric ticks including 0 + const generateTicks = (domain) => { + const [min, max] = domain; + const range = max - min; + let interval; + if (viewMode === "relative") { + interval = range <= 2 ? 0.5 : range <= 6 ? 1 : 2; + } else { + interval = range <= 100 ? 10 : range <= 200 ? 20 : 50; + } + const ticks = []; + for (let i = min; i <= max + 0.001; i += interval) { + ticks.push(Math.round(i * 100) / 100); // Avoid floating point issues + } + return ticks; + }; + + // Use fixed domain if provided (for consistent axis across years), otherwise calculate + const [yMin, yMax] = fixedYAxisDomain + ? (viewMode === "relative" ? fixedYAxisDomain.relative : fixedYAxisDomain.absolute) + : calculateSymmetricDomain(); + const yTicks = generateTicks([yMin, yMax]); return (
    @@ -239,9 +267,9 @@ export default function DecileChart({ /> [ formatValue(value), - name === "netChange" ? "Net change" : name + name === "Net change" ? "Net change" : name ]} labelFormatter={(label) => `${label} decile`} contentStyle={{ @@ -314,7 +342,7 @@ export default function DecileChart({ stroke="#000000" strokeWidth={2} dot={{ fill: "#000000", stroke: "#000000", strokeWidth: 1, r: 4 }} - name="netChange" + name="Net change" /> )} diff --git a/src/components/HouseholdCalculator.css b/src/components/HouseholdCalculator.css index 72699bd..3143ec8 100644 --- a/src/components/HouseholdCalculator.css +++ b/src/components/HouseholdCalculator.css @@ -67,6 +67,28 @@ margin-bottom: 8px; } +/* Year select dropdown */ +.year-select { + width: 100%; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.9rem; + color: #1e293b; + background: white; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.year-select:focus { + outline: none; + border-color: #319795; + box-shadow: 0 0 0 3px rgba(49, 151, 149, 0.1); +} + .input-group > .help-text { display: block; margin-top: 6px; @@ -158,7 +180,7 @@ } .age-input { - width: 70px; + flex: 1; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 6px; @@ -177,6 +199,7 @@ } .add-btn { + flex: 1; padding: 10px 16px; background: #319795; color: white; diff --git a/src/components/HouseholdCalculator.jsx b/src/components/HouseholdCalculator.jsx index 08237c9..78e401c 100644 --- a/src/components/HouseholdCalculator.jsx +++ b/src/components/HouseholdCalculator.jsx @@ -21,16 +21,18 @@ const DEFAULT_INPUTS = { receives_uc: true, // UC or other qualifying benefit for SCP }; -// Chart colors matching REFORMS +// Chart colors matching budget impact chart +// Teal = costs to government (good for households) +// Amber = revenue raisers (bad for households) const CHART_COLORS = { total: "#0F766E", // Teal 700 income_tax_basic_uplift: "#0D9488", // Teal 600 - income_tax_intermediate_uplift: "#14B8A6", // Teal 500 - higher_rate_freeze: "#F97316", // Orange 500 - advanced_rate_freeze: "#FB923C", // Orange 400 - top_rate_freeze: "#FDBA74", // Orange 300 - scp_inflation: "#2DD4BF", // Teal 400 - scp_baby_boost: "#5EEAD4", // Teal 300 + income_tax_intermediate_uplift: "#0F766E", // Teal 700 + scp_inflation: "#14B8A6", // Teal 500 + scp_baby_boost: "#2DD4BF", // Teal 400 + higher_rate_freeze: "#78350F", // Amber 900 (darkest) + advanced_rate_freeze: "#92400E", // Amber 800 + top_rate_freeze: "#B45309", // Amber 700 }; // Slider configurations @@ -51,7 +53,7 @@ function HouseholdCalculator() { const [childAgeInput, setChildAgeInput] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [selectedYear, setSelectedYear] = useState(2027); + const [selectedYear, setSelectedYear] = useState(2026); const [showRealTerms, setShowRealTerms] = useState(false); const [impacts, setImpacts] = useState({ income_tax_basic_uplift: 0, @@ -64,8 +66,11 @@ function HouseholdCalculator() { total: 0, }); const [yearlyData, setYearlyData] = useState([]); + const [byIncomeData, setByIncomeData] = useState([]); const yearlyChartRef = useRef(null); const yearlyChartContainerRef = useRef(null); + const incomeChartRef = useRef(null); + const incomeChartContainerRef = useRef(null); const years = [2026, 2027, 2028, 2029, 2030]; @@ -118,13 +123,18 @@ function HouseholdCalculator() { })); }, []); - // Combined calculate function - single API request for all data + // State for loading income chart separately + const [loadingIncomeChart, setLoadingIncomeChart] = useState(false); + + // Calculate function - uses single year endpoint for faster response const calculateAll = useCallback(async () => { setLoading(true); setError(null); + setByIncomeData([]); // Clear previous income data try { - const response = await fetch(`${API_BASE_URL}/calculate-all`, { + // First, get single year data (faster ~26s instead of ~2min) + const response = await fetch(`${API_BASE_URL}/calculate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...inputs, year: selectedYear }), @@ -135,49 +145,64 @@ function HouseholdCalculator() { throw new Error(result.error); } - // Process yearly data - setYearlyData(result.yearly); - - // Set current year impacts - const currentYearData = result.yearly.find((d) => d.year === selectedYear); - if (currentYearData) { - setImpacts({ - income_tax_basic_uplift: currentYearData.income_tax_basic_uplift, - income_tax_intermediate_uplift: currentYearData.income_tax_intermediate_uplift, - higher_rate_freeze: currentYearData.higher_rate_freeze, - advanced_rate_freeze: currentYearData.advanced_rate_freeze, - top_rate_freeze: currentYearData.top_rate_freeze, - scp_inflation: currentYearData.scp_inflation, - scp_baby_boost: currentYearData.scp_baby_boost, - total: currentYearData.total, - }); + // Set impacts from single year response + setImpacts({ + income_tax_basic_uplift: result.impacts.income_tax_basic_uplift, + income_tax_intermediate_uplift: result.impacts.income_tax_intermediate_uplift, + higher_rate_freeze: result.impacts.higher_rate_freeze, + advanced_rate_freeze: result.impacts.advanced_rate_freeze, + top_rate_freeze: result.impacts.top_rate_freeze, + scp_inflation: result.impacts.scp_inflation, + scp_baby_boost: result.impacts.scp_baby_boost, + total: result.total, + }); + + // Create yearly data from single year (for now, just show selected year) + setYearlyData([{ + year: selectedYear, + ...result.impacts, + total: result.total, + }]); + + setLoading(false); + + // Then fetch by_income data in background + setLoadingIncomeChart(true); + const incomeResponse = await fetch(`${API_BASE_URL}/calculate-by-income`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...inputs, year: selectedYear }), + }); + const incomeResult = await incomeResponse.json(); + + if (incomeResult.by_income) { + setByIncomeData(incomeResult.by_income); } } catch (err) { console.error("Error calculating:", err); setError(err.message); } finally { setLoading(false); + setLoadingIncomeChart(false); } }, [inputs, selectedYear]); - // Update impacts when year changes + // Track if we've calculated before (to know when to auto-recalculate on year change) + const hasCalculated = useRef(false); + + // Update hasCalculated when we get results useEffect(() => { if (yearlyData.length > 0) { - const currentYearData = yearlyData.find((d) => d.year === selectedYear); - if (currentYearData) { - setImpacts({ - income_tax_basic_uplift: currentYearData.income_tax_basic_uplift, - income_tax_intermediate_uplift: currentYearData.income_tax_intermediate_uplift, - higher_rate_freeze: currentYearData.higher_rate_freeze, - advanced_rate_freeze: currentYearData.advanced_rate_freeze, - top_rate_freeze: currentYearData.top_rate_freeze, - scp_inflation: currentYearData.scp_inflation, - scp_baby_boost: currentYearData.scp_baby_boost, - total: currentYearData.total, - }); - } + hasCalculated.current = true; + } + }, [yearlyData]); + + // Re-run calculation when year changes (if we've already calculated once) + useEffect(() => { + if (hasCalculated.current && !loading) { + calculateAll(); } - }, [selectedYear, yearlyData]); + }, [selectedYear]); // Only trigger on year change, not on calculateAll change // Draw yearly projection chart useEffect(() => { @@ -228,10 +253,21 @@ function HouseholdCalculator() { .range([0, width]) .padding(0.3); - // Dynamic Y scale based on actual data values (handle both positive and negative) - const allTotals = processedData.map((d) => d.total); - const dataMax = Math.max(...allTotals); - const dataMin = Math.min(...allTotals); + // Dynamic Y scale based on stacked values (sum positives and negatives separately) + const policyKeysForScale = ['income_tax_basic_uplift', 'income_tax_intermediate_uplift', 'higher_rate_freeze', 'advanced_rate_freeze', 'top_rate_freeze', 'scp_inflation', 'scp_baby_boost']; + let dataMax = 0; + let dataMin = 0; + processedData.forEach((d) => { + let posSum = 0; + let negSum = 0; + policyKeysForScale.forEach((key) => { + const val = d[key] || 0; + if (val > 0) posSum += val; + else negSum += val; + }); + dataMax = Math.max(dataMax, posSum); + dataMin = Math.min(dataMin, negSum); + }); const yMax = dataMax > 0 ? dataMax * 1.2 : 10; const yMin = dataMin < 0 ? dataMin * 1.2 : 0; const y = d3.scaleLinear().domain([yMin, yMax]).range([height, 0]).nice(); @@ -291,31 +327,63 @@ function HouseholdCalculator() { .attr("fill", "#6B7280") .attr("font-size", "11px"); - // Bars - simple total bar (handles positive and negative) + // Stacked bars - draw each policy component const zeroY = y(0); + const policyKeys = [ + { key: 'income_tax_basic_uplift', color: CHART_COLORS.income_tax_basic_uplift }, + { key: 'income_tax_intermediate_uplift', color: CHART_COLORS.income_tax_intermediate_uplift }, + { key: 'higher_rate_freeze', color: CHART_COLORS.higher_rate_freeze }, + { key: 'advanced_rate_freeze', color: CHART_COLORS.advanced_rate_freeze }, + { key: 'top_rate_freeze', color: CHART_COLORS.top_rate_freeze }, + { key: 'scp_inflation', color: CHART_COLORS.scp_inflation }, + { key: 'scp_baby_boost', color: CHART_COLORS.scp_baby_boost }, + ]; + processedData.forEach((d) => { - const barY = d.total >= 0 ? y(d.total) : zeroY; - const barHeight = Math.abs(y(d.total) - zeroY); + let posOffset = 0; // Tracks cumulative positive stack position + let negOffset = 0; // Tracks cumulative negative stack position - // Draw bar from zero line - if (barHeight > 0) { - g.append("rect") - .attr("class", `bar-${d.year}`) - .attr("x", x(d.year)) - .attr("y", barY) - .attr("width", x.bandwidth()) - .attr("height", barHeight) - .attr("fill", d.total >= 0 ? CHART_COLORS.income_tax_basic_uplift : "#F97316") - .attr("rx", 2); - } + policyKeys.forEach(({ key, color }) => { + const value = d[key] || 0; + if (Math.abs(value) < 0.01) return; // Skip zero values + + if (value >= 0) { + // Positive values stack upward from zero + const barHeight = zeroY - y(value); + g.append("rect") + .attr("x", x(d.year)) + .attr("y", zeroY - posOffset - barHeight) + .attr("width", x.bandwidth()) + .attr("height", barHeight) + .attr("fill", color); + posOffset += barHeight; + } else { + // Negative values stack downward from zero + const barHeight = y(value) - zeroY; + g.append("rect") + .attr("x", x(d.year)) + .attr("y", zeroY + negOffset) + .attr("width", x.bandwidth()) + .attr("height", barHeight) + .attr("fill", color); + negOffset += barHeight; + } + }); + + // Calculate total bar bounds for highlight + const totalPosHeight = posOffset; + const totalNegHeight = negOffset; + const barTop = zeroY - totalPosHeight; + const barBottom = zeroY + totalNegHeight; + const totalBarHeight = barBottom - barTop; // Highlight selected year - if (d.year === selectedYear) { + if (d.year === selectedYear && totalBarHeight > 0) { g.append("rect") .attr("x", x(d.year) - 2) - .attr("y", barY - 2) + .attr("y", barTop - 2) .attr("width", x.bandwidth() + 4) - .attr("height", barHeight + 4) + .attr("height", totalBarHeight + 4) .attr("fill", "none") .attr("stroke", CHART_COLORS.total) .attr("stroke-width", 2) @@ -395,27 +463,27 @@ function HouseholdCalculator() { ${formatVal(d.income_tax_basic_uplift)}
    - Intermediate uplift + Intermediate uplift ${formatVal(d.income_tax_intermediate_uplift)}
    - Higher freeze + Higher freeze ${formatVal(d.higher_rate_freeze)}
    - Advanced freeze + Advanced freeze ${formatVal(d.advanced_rate_freeze)}
    - Top rate freeze + Top rate freeze ${formatVal(d.top_rate_freeze)}
    - SCP inflation + SCP inflation ${formatVal(d.scp_inflation)}
    - SCP baby boost + SCP baby boost ${formatVal(d.scp_baby_boost)}
    @@ -436,6 +504,256 @@ function HouseholdCalculator() { }; }, [yearlyData, selectedYear, showRealTerms, toRealTerms]); + // Draw income level chart + useEffect(() => { + if ( + !byIncomeData.length || + !incomeChartRef.current || + !incomeChartContainerRef.current + ) + return; + + const svg = d3.select(incomeChartRef.current); + svg.selectAll("*").remove(); + + const containerWidth = incomeChartContainerRef.current.clientWidth; + const margin = { top: 20, right: 24, bottom: 50, left: 70 }; + const width = containerWidth - margin.left - margin.right; + const height = 220 - margin.top - margin.bottom; + + svg.attr("width", containerWidth).attr("height", 220); + + const g = svg + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Scales + const x = d3 + .scaleLinear() + .domain([0, 200000]) + .range([0, width]); + + const yMin = d3.min(byIncomeData, (d) => d.total); + const yMax = d3.max(byIncomeData, (d) => d.total); + const yPadding = Math.max(Math.abs(yMin), Math.abs(yMax)) * 0.1; + const y = d3 + .scaleLinear() + .domain([Math.min(yMin - yPadding, 0), Math.max(yMax + yPadding, 0)]) + .range([height, 0]) + .nice(); + + // Grid lines + g.append("g") + .attr("class", "grid-lines") + .selectAll("line") + .data(y.ticks(5)) + .enter() + .append("line") + .attr("x1", 0) + .attr("x2", width) + .attr("y1", (d) => y(d)) + .attr("y2", (d) => y(d)) + .attr("stroke", "#E2E8F0") + .attr("stroke-dasharray", "2,2"); + + // Zero line + g.append("line") + .attr("x1", 0) + .attr("x2", width) + .attr("y1", y(0)) + .attr("y2", y(0)) + .attr("stroke", "#94a3b8") + .attr("stroke-width", 1); + + // X axis + g.append("g") + .attr("transform", `translate(0,${height})`) + .call( + d3 + .axisBottom(x) + .ticks(5) + .tickFormat((d) => `£${d / 1000}k`) + .tickSize(0) + .tickPadding(10) + ) + .call((g) => g.select(".domain").attr("stroke", "#D1D5DB")) + .selectAll("text") + .attr("fill", "#6B7280") + .attr("font-size", "11px"); + + // X axis label + g.append("text") + .attr("x", width / 2) + .attr("y", height + 40) + .attr("text-anchor", "middle") + .attr("fill", "#6B7280") + .attr("font-size", "12px") + .text("Employment income"); + + // Y axis + g.append("g") + .call( + d3 + .axisLeft(y) + .ticks(5) + .tickFormat((d) => `£${d}`) + .tickSize(0) + .tickPadding(10) + ) + .call((g) => g.select(".domain").remove()) + .selectAll("text") + .attr("fill", "#6B7280") + .attr("font-size", "11px"); + + // Area fill + const area = d3 + .area() + .x((d) => x(d.income)) + .y0(y(0)) + .y1((d) => y(d.total)) + .curve(d3.curveMonotoneX); + + // Split data into positive and negative areas + const positiveData = byIncomeData.map((d) => ({ + income: d.income, + total: Math.max(0, d.total), + })); + const negativeData = byIncomeData.map((d) => ({ + income: d.income, + total: Math.min(0, d.total), + })); + + // Positive area (teal) + g.append("path") + .datum(positiveData) + .attr("fill", "rgba(13, 148, 136, 0.2)") + .attr("d", area); + + // Negative area (amber) + g.append("path") + .datum(negativeData) + .attr("fill", "rgba(180, 83, 9, 0.2)") + .attr("d", area); + + // Line + const line = d3 + .line() + .x((d) => x(d.income)) + .y((d) => y(d.total)) + .curve(d3.curveMonotoneX); + + g.append("path") + .datum(byIncomeData) + .attr("fill", "none") + .attr("stroke", CHART_COLORS.total) + .attr("stroke-width", 2) + .attr("d", line); + + + // Vertical hover line + const hoverLine = g.append("line") + .attr("class", "hover-line") + .attr("y1", 0) + .attr("y2", height) + .attr("stroke", "#94a3b8") + .attr("stroke-width", 1) + .attr("stroke-dasharray", "4,4") + .style("opacity", 0) + .style("pointer-events", "none"); + + // Tooltip + d3.select(incomeChartContainerRef.current).style("position", "relative"); + const tooltip = d3 + .select(incomeChartContainerRef.current) + .append("div") + .attr("class", "income-chart-tooltip") + .style("position", "absolute") + .style("background", "white") + .style("border", "1px solid #e2e8f0") + .style("border-radius", "8px") + .style("padding", "10px") + .style("font-size", "11px") + .style("box-shadow", "0 4px 12px rgba(0,0,0,0.1)") + .style("pointer-events", "none") + .style("opacity", 0) + .style("z-index", 10); + + // Hover area + g.append("rect") + .attr("width", width) + .attr("height", height) + .attr("fill", "transparent") + .on("mousemove", (event) => { + const [mouseX] = d3.pointer(event); + const income = x.invert(mouseX); + const closest = byIncomeData.reduce((prev, curr) => + Math.abs(curr.income - income) < Math.abs(prev.income - income) ? curr : prev + ); + + // Update vertical line position + hoverLine + .attr("x1", x(closest.income)) + .attr("x2", x(closest.income)) + .style("opacity", 1); + + const formatVal = (v) => { + if (Math.abs(v) < 0.01) return "£0"; + const sign = v < 0 ? "-" : "+"; + return `${sign}£${Math.abs(v).toFixed(0)}`; + }; + + tooltip + .html(` +
    + £${closest.income.toLocaleString()} income +
    +
    + Basic rate uplift + ${formatVal(closest.income_tax_basic_uplift)} +
    +
    + Intermediate uplift + ${formatVal(closest.income_tax_intermediate_uplift)} +
    +
    + Higher freeze + ${formatVal(closest.higher_rate_freeze)} +
    +
    + Advanced freeze + ${formatVal(closest.advanced_rate_freeze)} +
    +
    + Top rate freeze + ${formatVal(closest.top_rate_freeze)} +
    +
    + SCP inflation + ${formatVal(closest.scp_inflation)} +
    +
    + SCP baby boost + ${formatVal(closest.scp_baby_boost)} +
    +
    + Total + ${formatVal(closest.total)} +
    + `) + .style("opacity", 1) + .style("left", `${x(closest.income) + margin.left - 200}px`) + .style("top", `${margin.top + 10}px`); + }) + .on("mouseout", () => { + tooltip.style("opacity", 0); + hoverLine.style("opacity", 0); + }); + + return () => { + tooltip.remove(); + }; + }, [byIncomeData, inputs.employment_income]); + // Format currency const formatCurrency = useCallback( (value, showSign = true) => { @@ -469,6 +787,22 @@ function HouseholdCalculator() {

    Household details

    + {/* Year selector dropdown */} +
    + + +
    + {/* Employment income slider */} {SLIDER_CONFIGS.map((config) => (
    @@ -534,23 +868,6 @@ function HouseholdCalculator() {
    )} - {/* UC eligibility */} -
    - - - Required for Scottish Child Payment - -
    - {/* Children */}
    @@ -609,22 +926,8 @@ function HouseholdCalculator() { {/* Results */}
    - {/* Year selector and real terms toggle */} + {/* Real terms toggle */}
    -
    - -
    - {years.map((year) => ( - - ))} -
    -
    )} - {/* Total impact card */} - {!loading && ( + {/* Prompt to calculate - shown when no calculation has been done */} + {!loading && yearlyData.length === 0 && ( +
    +
    + Enter household details and click Calculate to see the impacts +
    +
    + )} + + {/* Total impact card - shown after calculation */} + {!loading && yearlyData.length > 0 && (
    0 ? "positive" : impacts.total < 0 ? "negative" : "neutral"}`} > @@ -669,8 +981,8 @@ function HouseholdCalculator() {
    )} - {/* Breakdown by reform */} - {!loading && ( + {/* Breakdown by reform - only shown after calculation */} + {!loading && yearlyData.length > 0 && (

    Breakdown by policy

    {REFORMS.map((reform) => { @@ -697,19 +1009,27 @@ function HouseholdCalculator() {
    )} - {/* Yearly projection chart */} - {!loading && yearlyData.length > 0 && ( + {/* Impact by income level chart */} + {!loading && (loadingIncomeChart || byIncomeData.length > 0) && (
    -

    Impact over time

    +

    Impact by income level

    - Click a year to see detailed breakdown + How the budget affects households at different income levels in {selectedYear}-{(selectedYear + 1).toString().slice(-2)}

    -
    - -
    + {loadingIncomeChart && byIncomeData.length === 0 ? ( +
    +
    + Loading income chart... +
    + ) : ( +
    + +
    + )}
    )} diff --git a/src/components/LocalAreaSection.jsx b/src/components/LocalAreaSection.jsx index 3efac65..cfcbe81 100644 --- a/src/components/LocalAreaSection.jsx +++ b/src/components/LocalAreaSection.jsx @@ -152,6 +152,50 @@ export default function LocalAreaSection({ })); }, [localAuthorityData]); + // Calculate fixed color extent across ALL years for consistent map coloring + const fixedColorExtent = useMemo(() => { + if (!rawLocalAuthorityData.length || !selectedPolicies.length) return null; + + let globalMin = Infinity; + let globalMax = -Infinity; + + // Check all years + availableYears.forEach(year => { + // Group by local authority for this year + const laMap = new Map(); + + rawLocalAuthorityData.forEach((row) => { + if (!selectedPolicies.includes(row.reform_id)) return; + if (row.year !== year) return; + + const key = row.code; + if (!laMap.has(key)) { + laMap.set(key, 0); + } + laMap.set(key, laMap.get(key) + row.avgGain); + }); + + // Find min/max for this year + laMap.forEach(value => { + globalMin = Math.min(globalMin, value); + globalMax = Math.max(globalMax, value); + }); + }); + + if (globalMin === Infinity || globalMax === -Infinity) return null; + + // Determine type + let type = 'mixed'; + if (globalMin >= 0) type = 'positive'; + else if (globalMax <= 0) type = 'negative'; + + return { + min: Math.floor(globalMin), + max: Math.ceil(globalMax), + type + }; + }, [rawLocalAuthorityData, selectedPolicies, availableYears]); + // Handle local authority selection from map const handleLocalAuthoritySelect = (laData) => { if (laData) { @@ -219,6 +263,7 @@ export default function LocalAreaSection({ onLocalAuthoritySelect={handleLocalAuthoritySelect} policyName={policyName} selectedPolicies={selectedPolicies} + fixedColorExtent={fixedColorExtent} />
    diff --git a/src/components/SFCComparisonTable.css b/src/components/SFCComparisonTable.css index 1dc4d44..e923a72 100644 --- a/src/components/SFCComparisonTable.css +++ b/src/components/SFCComparisonTable.css @@ -5,7 +5,7 @@ } .sfc-comparison-section h2 { - font-size: 1.25rem; + font-size: 1.4rem; font-weight: 600; color: #374151; margin: 0 0 12px 0; diff --git a/src/components/SFCComparisonTable.jsx b/src/components/SFCComparisonTable.jsx index 180736a..dddb9f6 100644 --- a/src/components/SFCComparisonTable.jsx +++ b/src/components/SFCComparisonTable.jsx @@ -139,7 +139,7 @@ function SFCComparisonTable() { return (
    -

    PolicyEngine vs SFC comparison

    +

    PolicyEngine vs SFC comparison

    This table compares PolicyEngine's static microsimulation estimates with the Scottish Fiscal Commission's official costings from the January 2026 @@ -234,38 +234,44 @@ function SFCComparisonTable() {

    -

    - Note: PolicyEngine produces static microsimulation - estimates that do not include behavioural responses.{" "} - {showBehavioural - ? "Post-behavioural costings include effects like tax avoidance, reduced consumption, and migration. SFC assumes behavioural responses reduce yields by ~8% for higher-rate freezes, ~25% for advanced-rate, and ~85% for top-rate." - : "Static costings assume no change in taxpayer behaviour. Static values shown here are derived from post-behavioural figures using SFC's published behavioural adjustment rates."}{" "} - Each provision is costed independently against baseline (not stacked). -

    -

    - Data notes: Threshold freezes show 2026-27 as empty because - the freeze was already in Budget 2025-26; SFC only costs the incremental - extension through 2027-28/2028-29. SCP baby boost starts mid-2027-28. - SFC reports combined basic + intermediate thresholds (~£50m total); we - apportion using PolicyEngine microsimulation. SCP inflation uprating has - no SFC costing as it's included in their baseline. See{" "} - - SFC January 2026 Forecasts - {" "} - and{" "} - - IFS analysis - {" "} - for methodology. -

    +
    + Note +

    + PolicyEngine produces static microsimulation + estimates that do not include behavioural responses.{" "} + {showBehavioural + ? "Post-behavioural costings include effects like tax avoidance, reduced consumption, and migration. SFC assumes behavioural responses reduce yields by ~8% for higher-rate freezes, ~25% for advanced-rate, and ~85% for top-rate." + : "Static costings assume no change in taxpayer behaviour. Static values shown here are derived from post-behavioural figures using SFC's published behavioural adjustment rates."}{" "} + Each provision is costed independently against baseline (not stacked). +

    +
    +
    + Data notes +

    + Threshold freezes show 2026-27 as empty because + the freeze was already in Budget 2025-26; SFC only costs the incremental + extension through 2027-28/2028-29. SCP baby boost starts mid-2027-28. + SFC reports combined basic + intermediate thresholds (~£50m total); we + apportion using PolicyEngine microsimulation. SCP inflation uprating has + no SFC costing as it's included in their baseline. See{" "} + + SFC January 2026 Forecasts + {" "} + and{" "} + + IFS analysis + {" "} + for methodology. +

    +
    ); } diff --git a/src/components/ScotlandMap.jsx b/src/components/ScotlandMap.jsx index a887121..8256017 100644 --- a/src/components/ScotlandMap.jsx +++ b/src/components/ScotlandMap.jsx @@ -10,8 +10,11 @@ const formatYearRange = (year) => `${year}-${(year + 1).toString().slice(-2)}`; const POLICY_DISPLAY_NAMES = { scp_baby_boost: "SCP Premium for under-ones", scp_inflation: "SCP inflation adjustment", - income_tax_basic_uplift: "Basic rate +7.4%", - income_tax_intermediate_uplift: "Intermediate rate +7.4%", + income_tax_basic_uplift: "Basic rate threshold uplift", + income_tax_intermediate_uplift: "Intermediate rate threshold uplift", + higher_rate_freeze: "Higher rate threshold freeze", + advanced_rate_freeze: "Advanced rate threshold freeze", + top_rate_freeze: "Top rate threshold freeze", }; export default function ScotlandMap({ @@ -23,6 +26,7 @@ export default function ScotlandMap({ onLocalAuthoritySelect = null, policyName = "SCP Premium for under-ones", selectedPolicies = [], + fixedColorExtent = null, }) { const svgRef = useRef(null); const [internalSelectedLocalAuthority, setInternalSelectedLocalAuthority] = useState(null); @@ -75,7 +79,10 @@ export default function ScotlandMap({ }, [localAuthorityData]); // Compute color scale extent from data (min/max of average_gain) + // Use fixedColorExtent if provided for consistent coloring across years const colorExtent = useMemo(() => { + if (fixedColorExtent) return fixedColorExtent; + if (localAuthorityData.length === 0) return { min: 0, max: 35, type: 'positive' }; const gains = localAuthorityData.map((d) => d.average_gain || 0); const min = Math.floor(Math.min(...gains)); @@ -87,7 +94,7 @@ export default function ScotlandMap({ else if (max <= 0) type = 'negative'; return { min, max, type }; - }, [localAuthorityData]); + }, [localAuthorityData, fixedColorExtent]); // Highlight and zoom to controlled local authority when it changes useEffect(() => { @@ -452,7 +459,7 @@ export default function ScotlandMap({

    Local authority impacts, {formatYearRange(selectedYear)}

    - This map shows the average annual household gain from the {policyName} + This map shows the average annual household impact from the {policyName} across Scottish local authorities. Darker green indicates larger gains.

    @@ -622,7 +629,7 @@ export default function ScotlandMap({ })} /year

    -

    Average household gain

    +

    Average household impact

    {/* Policy breakdown - only show if multiple policies selected */} {tooltipData.policyBreakdown && @@ -635,7 +642,7 @@ export default function ScotlandMap({ .map(([reformId, data]) => (
    - {POLICY_DISPLAY_NAMES[reformId] || reformId} + {POLICY_DISPLAY_NAMES[reformId]} )} - {tooltipData.povertyReduction !== undefined && ( - <> -

    - -{parseFloat(tooltipData.povertyReduction).toFixed(2)}pp -

    -

    Poverty rate reduction

    - - )}
    )}
    diff --git a/src/scottish_budget_data/calculators.py b/src/scottish_budget_data/calculators.py index 6bab351..96aa092 100644 --- a/src/scottish_budget_data/calculators.py +++ b/src/scottish_budget_data/calculators.py @@ -1,11 +1,12 @@ """Calculators for Scottish Budget dashboard metrics. Each calculator generates a specific type of output data. +Uses native MicroSeries from PolicyEngine - sim.calculate() returns MicroSeries with weights. """ +import microdf as mdf import numpy as np import pandas as pd -from microdf import MicroSeries from policyengine_uk import Microsimulation from .reforms import ( @@ -77,6 +78,7 @@ def calculate(self, reform_id: str, reform_name: str) -> list[dict]: """Calculate budgetary impact for all years (Scotland only). Uses fresh simulations per year with proper Reform classes. + sim.calculate() returns MicroSeries with weights - .sum() is weighted. Returns cost in £ millions. Positive = cost to government (income gain for households). """ @@ -95,12 +97,14 @@ def calculate(self, reform_id: str, reform_name: str) -> list[dict]: is_scotland = get_scotland_household_mask(baseline, year) + # sim.calculate() returns MicroSeries with weights baseline_income = baseline.calculate("household_net_income", year) reformed_income = reformed.calculate("household_net_income", year) - # Calculate impact: negative = cost to government, positive = revenue - # (matches OBR/SFC convention used in autumn budget dashboard) - household_change = (reformed_income[is_scotland] - baseline_income[is_scotland]).sum() + # MicroSeries subtraction preserves weights, .sum() is weighted + income_change = reformed_income - baseline_income + household_change = income_change[is_scotland].sum() + # Negate because household income gain = cost to government impact = -household_change / 1e6 @@ -118,6 +122,7 @@ class DistributionalImpactCalculator: """Calculate distributional impact by income decile. Uses fresh simulations per year with proper PolicyEngine Reform classes. + sim.calculate() returns MicroSeries with weights built in. """ def calculate( @@ -125,10 +130,10 @@ def calculate( reform_id: str, reform_name: str, year: int, - ) -> tuple[list[dict], pd.DataFrame]: + ) -> list[dict]: """Calculate distributional impact for a single year (Scotland only). - Uses fresh simulations with proper Reform classes. + Uses native MicroSeries from sim.calculate() - no manual weight handling. """ baseline = Microsimulation() reformed = Microsimulation() @@ -142,32 +147,28 @@ def calculate( is_scotland = get_scotland_household_mask(baseline, year) - baseline_income = np.array(baseline.calculate("household_net_income", year))[is_scotland] - reformed_income = np.array(reformed.calculate("household_net_income", year))[is_scotland] - household_weight = np.array(baseline.calculate("household_weight", year))[is_scotland] - income_decile = np.array(baseline.calculate("household_income_decile", year))[is_scotland] + # sim.calculate() returns MicroSeries with weights + baseline_income = baseline.calculate("household_net_income", year) + reformed_income = reformed.calculate("household_net_income", year) + income_decile = baseline.calculate("household_income_decile", year) - df = pd.DataFrame({ - "baseline_income": baseline_income, - "reformed_income": reformed_income, - "household_weight": household_weight, - "income_decile": income_decile, - }) - - df["income_change"] = df["reformed_income"] - df["baseline_income"] - df["income_decile"] = pd.to_numeric(df["income_decile"], errors="coerce").clip(1, 10).astype(int) + # Filter to Scotland - MicroSeries preserves weights when filtered + baseline_scotland = baseline_income[is_scotland] + reformed_scotland = reformed_income[is_scotland] + income_change = reformed_scotland - baseline_scotland + decile_scotland = income_decile[is_scotland] results = [] decile_labels = ["1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th"] for decile in range(1, 11): - decile_data = df[df["income_decile"] == decile] - if len(decile_data) == 0: + decile_mask = np.array(decile_scotland) == decile + if not decile_mask.any(): continue - weights = decile_data["household_weight"].values - avg_change = MicroSeries(decile_data["income_change"].values, weights=weights).mean() - avg_baseline = MicroSeries(decile_data["baseline_income"].values, weights=weights).mean() + # Use native MicroSeries - .mean() is weighted automatically + avg_change = income_change[decile_mask].mean() + avg_baseline = baseline_scotland[decile_mask].mean() relative_change = (avg_change / avg_baseline) * 100 if avg_baseline > 0 else 0 results.append({ @@ -180,9 +181,8 @@ def calculate( }) # Add overall average (All deciles) - overall_weights = df["household_weight"].values - overall_avg_change = MicroSeries(df["income_change"].values, weights=overall_weights).mean() - overall_avg_baseline = MicroSeries(df["baseline_income"].values, weights=overall_weights).mean() + overall_avg_change = income_change.mean() + overall_avg_baseline = baseline_scotland.mean() overall_relative_change = (overall_avg_change / overall_avg_baseline) * 100 if overall_avg_baseline > 0 else 0 results.append({ @@ -194,55 +194,7 @@ def calculate( "absolute_change": overall_avg_change, }) - return results, df - - -class WinnersLosersCalculator: - """Calculate winners and losers statistics.""" - - def calculate( - self, - decile_df: pd.DataFrame, - reform_id: str, - reform_name: str, - year: int, - ) -> list[dict]: - """Calculate winners/losers from decile DataFrame.""" - # Create MicroSeries with household weights for proper weighted sums - weights = decile_df["household_weight"].values - ones = np.ones(len(decile_df)) - total_weight = MicroSeries(ones, weights=weights).sum() - - winner_mask = decile_df["income_change"] > 1 - loser_mask = decile_df["income_change"] < -1 - - winners = MicroSeries(ones[winner_mask], weights=weights[winner_mask]).sum() - losers = MicroSeries(ones[loser_mask], weights=weights[loser_mask]).sum() - unchanged = total_weight - winners - losers - - return [ - { - "reform_id": reform_id, - "reform_name": reform_name, - "year": year, - "metric": "winners_pct", - "value": (winners / total_weight) * 100, - }, - { - "reform_id": reform_id, - "reform_name": reform_name, - "year": year, - "metric": "losers_pct", - "value": (losers / total_weight) * 100, - }, - { - "reform_id": reform_id, - "reform_name": reform_name, - "year": year, - "metric": "unchanged_pct", - "value": (unchanged / total_weight) * 100, - }, - ] + return results class MetricsCalculator: @@ -256,22 +208,31 @@ def calculate( reform_name: str, year: int, ) -> list[dict]: - """Calculate poverty and other summary metrics (Scotland only).""" + """Calculate poverty and other summary metrics (Scotland only). + + Uses native MicroSeries from sim.calculate() - no manual weight handling. + """ is_scotland = get_scotland_person_mask(baseline, year) - person_weight = baseline.calculate("person_weight", year, map_to="person").values[is_scotland] - is_child = baseline.calculate("is_child", year, map_to="person").values[is_scotland] - child_weights = person_weight * is_child + + # Get is_child for filtering to children + is_child = baseline.calculate("is_child", year, map_to="person") + is_child_scotland = np.array(is_child[is_scotland]) def add_metric_set( results: list[dict], metric_prefix: str, - baseline_values: np.ndarray, - reformed_values: np.ndarray, - weights: np.ndarray, + baseline_ms, # MicroSeries + reformed_ms, # MicroSeries + child_filter: np.ndarray = None, ) -> None: """Add baseline, reform, and change metrics for a given measure.""" - baseline_rate = MicroSeries(baseline_values, weights=weights).mean() * 100 - reformed_rate = MicroSeries(reformed_values, weights=weights).mean() * 100 + if child_filter is not None: + # Filter to children + baseline_rate = baseline_ms[child_filter].mean() * 100 + reformed_rate = reformed_ms[child_filter].mean() * 100 + else: + baseline_rate = baseline_ms.mean() * 100 + reformed_rate = reformed_ms.mean() * 100 results.append({ "reform_id": reform_id, @@ -310,20 +271,29 @@ def add_metric_set( poverty_var = f"in_relative_poverty_{housing_cost}" deep_poverty_var = None - # Regular poverty - baseline_poverty = baseline.calculate(poverty_var, year, map_to="person").values[is_scotland] - reformed_poverty = reformed.calculate(poverty_var, year, map_to="person").values[is_scotland] + # sim.calculate() returns MicroSeries with weights + baseline_poverty = baseline.calculate(poverty_var, year, map_to="person") + reformed_poverty = reformed.calculate(poverty_var, year, map_to="person") + + # Filter to Scotland - MicroSeries preserves weights + baseline_scotland = baseline_poverty[is_scotland] + reformed_scotland = reformed_poverty[is_scotland] - add_metric_set(results, f"{prefix}poverty_rate", baseline_poverty, reformed_poverty, person_weight) - add_metric_set(results, f"{prefix}child_poverty_rate", baseline_poverty, reformed_poverty, child_weights) + # All persons poverty rate + add_metric_set(results, f"{prefix}poverty_rate", baseline_scotland, reformed_scotland) + # Child poverty rate + add_metric_set(results, f"{prefix}child_poverty_rate", baseline_scotland, reformed_scotland, is_child_scotland) # Deep poverty (only for absolute poverty measure) if deep_poverty_var: - baseline_deep = baseline.calculate(deep_poverty_var, year, map_to="person").values[is_scotland] - reformed_deep = reformed.calculate(deep_poverty_var, year, map_to="person").values[is_scotland] + baseline_deep = baseline.calculate(deep_poverty_var, year, map_to="person") + reformed_deep = reformed.calculate(deep_poverty_var, year, map_to="person") + + baseline_deep_scotland = baseline_deep[is_scotland] + reformed_deep_scotland = reformed_deep[is_scotland] - add_metric_set(results, f"{prefix}deep_poverty_rate", baseline_deep, reformed_deep, person_weight) - add_metric_set(results, f"{prefix}child_deep_poverty_rate", baseline_deep, reformed_deep, child_weights) + add_metric_set(results, f"{prefix}deep_poverty_rate", baseline_deep_scotland, reformed_deep_scotland) + add_metric_set(results, f"{prefix}child_deep_poverty_rate", baseline_deep_scotland, reformed_deep_scotland, is_child_scotland) return results @@ -339,7 +309,10 @@ def calculate( sim_with_limit: Microsimulation, sim_without_limit: Microsimulation, ) -> list[dict]: - """Calculate two-child limit abolition impact for Scotland.""" + """Calculate two-child limit abolition impact for Scotland. + + Uses native MicroSeries from sim.calculate() - no manual weight handling. + """ # SFC estimates for comparison SFC_DATA = { 2026: {"children": 43000, "cost": 155}, @@ -352,27 +325,30 @@ def calculate( results = [] for year in self.years: - region = sim_without_limit.calculate("region", year, map_to="household").values - scotland_mask = region == "SCOTLAND" - hh_weight = sim_without_limit.calculate("household_weight", year, map_to="household").values + # sim.calculate() returns MicroSeries with weights + region = sim_without_limit.calculate("region", year, map_to="household") + scotland_mask = np.array(region) == "SCOTLAND" - uc_without_limit = sim_without_limit.calculate("universal_credit", year, map_to="household").values - uc_with_limit = sim_with_limit.calculate("universal_credit", year, map_to="household").values + uc_without_limit = sim_without_limit.calculate("universal_credit", year, map_to="household") + uc_with_limit = sim_with_limit.calculate("universal_credit", year, map_to="household") + + # MicroSeries subtraction preserves weights uc_gain = uc_without_limit - uc_with_limit + affected_mask = scotland_mask & (np.array(uc_gain) > 0) - affected_mask = scotland_mask & (uc_gain > 0) - affected_weights = hh_weight[affected_mask] - total_cost = MicroSeries(uc_gain[affected_mask], weights=affected_weights).sum() + # .sum() on MicroSeries is weighted + total_cost = uc_gain[affected_mask].sum() total_cost_millions = total_cost / 1e6 - affected_benefit_units = MicroSeries(np.ones(affected_weights.shape), weights=affected_weights).sum() - benunit_children = sim_without_limit.calculate( - "benunit_count_children", year, map_to="household" - ).values - affected_children_per_hh = np.maximum(benunit_children - 2, 0) - total_affected_children = MicroSeries( - affected_children_per_hh[affected_mask], weights=affected_weights - ).sum() + # Count affected benefit units using MicroSeries weights + affected_benefit_units = uc_gain.weights[affected_mask].sum() + + # Count affected children - need MicroSeries for calculated values + benunit_children = sim_without_limit.calculate("benunit_count_children", year, map_to="household") + affected_children_per_hh = np.maximum(np.array(benunit_children) - 2, 0) + # Use weights from uc_gain MicroSeries (same household weights) + children_ms = mdf.MicroSeries(affected_children_per_hh, weights=uc_gain.weights.values) + total_affected_children = children_ms[affected_mask].sum() results.append({ "year": year, @@ -387,7 +363,11 @@ def calculate( class LocalAuthorityCalculator: - """Calculate local authority-level impacts.""" + """Calculate local authority-level impacts. + + Note: This uses external weights from HuggingFace (not simulation weights), + so we need to explicitly create MicroSeries with those weights. + """ def calculate( self, @@ -399,12 +379,9 @@ def calculate( local_authority_df: pd.DataFrame, ) -> list[dict]: """Calculate average impact for each local authority.""" - baseline_income = baseline.calculate( - "household_net_income", period=year, map_to="household" - ).values - reform_income = reformed.calculate( - "household_net_income", period=year, map_to="household" - ).values + # Get values as arrays (we use external LA weights, not simulation weights) + baseline_income = np.array(baseline.calculate("household_net_income", period=year, map_to="household")) + reform_income = np.array(reformed.calculate("household_net_income", period=year, map_to="household")) results = [] @@ -417,8 +394,9 @@ def calculate( la_weights = weights[idx, :] - baseline_ms = MicroSeries(baseline_income, weights=la_weights) - reform_ms = MicroSeries(reform_income, weights=la_weights) + # Use external LA weights with MicroSeries + baseline_ms = mdf.MicroSeries(baseline_income, weights=la_weights) + reform_ms = mdf.MicroSeries(reform_income, weights=la_weights) avg_baseline = baseline_ms.mean() avg_reform = reform_ms.mean() diff --git a/src/scottish_budget_data/modal_app.py b/src/scottish_budget_data/modal_app.py index 13eef06..50261fb 100644 --- a/src/scottish_budget_data/modal_app.py +++ b/src/scottish_budget_data/modal_app.py @@ -107,6 +107,170 @@ def create_situation(inputs: dict, year: int) -> dict: }, } + def create_vectorized_situation(inputs: dict, year: int, income_levels: list) -> dict: + """Create a vectorized situation with multiple households at different income levels. + + This allows computing impacts for 100 income levels in a single simulation. + """ + import numpy as np + + is_married = inputs.get("is_married", False) + partner_income = inputs.get("partner_income", 0) + children_ages = inputs.get("children_ages", []) + n_households = len(income_levels) + + people = {} + benunits = {} + households = {} + + for i, income in enumerate(income_levels): + hh_id = f"hh{i}" + adult1_id = f"adult1_{i}" + + people[adult1_id] = { + "age": {year: 35}, + "employment_income": {year: float(income)}, + } + members = [adult1_id] + + if is_married: + adult2_id = f"adult2_{i}" + people[adult2_id] = { + "age": {year: 33}, + "employment_income": {year: float(partner_income)}, + } + members.append(adult2_id) + + for j, age in enumerate(children_ages): + child_id = f"child{j}_{i}" + people[child_id] = {"age": {year: int(age)}} + members.append(child_id) + + benunits[f"benunit_{i}"] = {"members": members} + households[hh_id] = {"members": members, "region": {year: "SCOTLAND"}} + + return { + "people": people, + "benunits": benunits, + "households": households, + } + + def calculate_vectorized_by_income(inputs: dict, year: int, receives_uc: bool) -> list: + """Calculate impacts for 100 income levels using vectorization. + + Returns list of {income, total, ...impacts} dicts. + """ + import numpy as np + + # 50 income levels from £0 to £200k (faster computation) + income_levels = list(range(0, 200001, 4000)) # 0, 4000, 8000, ..., 200000 + n = len(income_levels) + + situation = create_vectorized_situation(inputs, year, income_levels) + + # Baseline simulation + baseline_sim = Simulation(situation=situation) + set_scp_baseline_rate(baseline_sim, year) + disable_scp_baby_boost(baseline_sim, year) + baseline_sim.calculate("scottish_child_payment", year) + baseline_nets = baseline_sim.calculate("household_net_income", year) + + results = { + "income_tax_basic_uplift": np.zeros(n), + "income_tax_intermediate_uplift": np.zeros(n), + "higher_rate_freeze": np.zeros(n), + "advanced_rate_freeze": np.zeros(n), + "top_rate_freeze": np.zeros(n), + "scp_inflation": np.zeros(n), + "scp_baby_boost": np.zeros(n), + } + + # 1. Basic rate uplift + basic_sim = Simulation(situation=situation) + set_scp_baseline_rate(basic_sim, year) + disable_scp_baby_boost(basic_sim, year) + apply_basic_rate_uplift(basic_sim, year) + basic_sim.calculate("scottish_child_payment", year) + basic_nets = basic_sim.calculate("household_net_income", year) + results["income_tax_basic_uplift"] = basic_nets - baseline_nets + + # 2. Intermediate rate uplift + intermediate_sim = Simulation(situation=situation) + set_scp_baseline_rate(intermediate_sim, year) + disable_scp_baby_boost(intermediate_sim, year) + apply_intermediate_rate_uplift(intermediate_sim, year) + intermediate_sim.calculate("scottish_child_payment", year) + intermediate_nets = intermediate_sim.calculate("household_net_income", year) + results["income_tax_intermediate_uplift"] = intermediate_nets - baseline_nets + + # 3. Higher rate freeze + higher_sim = Simulation(situation=situation) + set_scp_baseline_rate(higher_sim, year) + disable_scp_baby_boost(higher_sim, year) + apply_higher_rate_freeze(higher_sim, year) + higher_sim.calculate("scottish_child_payment", year) + higher_nets = higher_sim.calculate("household_net_income", year) + results["higher_rate_freeze"] = higher_nets - baseline_nets + + # 4. Advanced rate freeze + advanced_sim = Simulation(situation=situation) + set_scp_baseline_rate(advanced_sim, year) + disable_scp_baby_boost(advanced_sim, year) + apply_advanced_rate_freeze(advanced_sim, year) + advanced_sim.calculate("scottish_child_payment", year) + advanced_nets = advanced_sim.calculate("household_net_income", year) + results["advanced_rate_freeze"] = advanced_nets - baseline_nets + + # 5. Top rate freeze + top_sim = Simulation(situation=situation) + set_scp_baseline_rate(top_sim, year) + disable_scp_baby_boost(top_sim, year) + apply_top_rate_freeze(top_sim, year) + top_sim.calculate("scottish_child_payment", year) + top_nets = top_sim.calculate("household_net_income", year) + results["top_rate_freeze"] = top_nets - baseline_nets + + # 6. SCP inflation (only if receives UC) + if receives_uc: + scp_inf_sim = Simulation(situation=situation) + apply_scp_inflation(scp_inf_sim, year) + disable_scp_baby_boost(scp_inf_sim, year) + scp_inf_sim.calculate("scottish_child_payment", year) + scp_inf_nets = scp_inf_sim.calculate("household_net_income", year) + results["scp_inflation"] = scp_inf_nets - baseline_nets + + # 7. SCP baby boost (only if receives UC and year >= 2027) + if receives_uc and year >= 2027: + baby_sim = Simulation(situation=situation) + apply_scp_inflation(baby_sim, year) + apply_scp_baby_boost(baby_sim, year) + baby_sim.calculate("scottish_child_payment", year) + baby_nets = baby_sim.calculate("household_net_income", year) + no_baby_sim = Simulation(situation=situation) + apply_scp_inflation(no_baby_sim, year) + disable_scp_baby_boost(no_baby_sim, year) + no_baby_sim.calculate("scottish_child_payment", year) + no_baby_nets = no_baby_sim.calculate("household_net_income", year) + results["scp_baby_boost"] = baby_nets - no_baby_nets + + # Build output list + output = [] + for i, income in enumerate(income_levels): + total = sum(float(results[k][i]) for k in results) + output.append({ + "income": income, + "income_tax_basic_uplift": round(float(results["income_tax_basic_uplift"][i]), 2), + "income_tax_intermediate_uplift": round(float(results["income_tax_intermediate_uplift"][i]), 2), + "higher_rate_freeze": round(float(results["higher_rate_freeze"][i]), 2), + "advanced_rate_freeze": round(float(results["advanced_rate_freeze"][i]), 2), + "top_rate_freeze": round(float(results["top_rate_freeze"][i]), 2), + "scp_inflation": round(float(results["scp_inflation"][i]), 2), + "scp_baby_boost": round(float(results["scp_baby_boost"][i]), 2), + "total": round(total, 2), + }) + + return output + def apply_basic_rate_uplift(sim, year: int) -> None: """Apply basic rate threshold uplift (7.4% in 2026, then CPI uprated).""" scotland_rates = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.scotland.rates @@ -371,12 +535,15 @@ def calculate_for_year(inputs: dict, year: int, receives_uc: bool) -> dict: @flask_app.route("/calculate-all", methods=["POST"]) def calculate_all(): - """Combined endpoint: returns yearly data (2026-2030) in one request.""" + """Combined endpoint: returns yearly data (2026-2030) in one request. + + by_income is now a separate endpoint to keep this fast. + """ try: inputs = request.get_json() receives_uc = inputs.get("receives_uc", True) - # Calculate for all years + # Calculate for all years (sequential - each year is fast) years = [2026, 2027, 2028, 2029, 2030] yearly_data = [calculate_for_year(inputs, year, receives_uc) for year in years] @@ -388,6 +555,24 @@ def calculate_all(): traceback.print_exc() return jsonify({"error": str(e)}), 500 + @flask_app.route("/calculate-by-income", methods=["POST"]) + def calculate_by_income(): + """Separate endpoint for by_income data (vectorized 50 income levels).""" + try: + inputs = request.get_json() + receives_uc = inputs.get("receives_uc", True) + year = inputs.get("year", 2027) + + by_income_data = calculate_vectorized_by_income(inputs, year, receives_uc) + + return jsonify({ + "by_income": by_income_data, + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({"error": str(e)}), 500 + @flask_app.route("/health", methods=["GET"]) def health(): return jsonify({"status": "healthy"}) diff --git a/src/scottish_budget_data/pipeline.py b/src/scottish_budget_data/pipeline.py index 5592f5d..f34d5a0 100644 --- a/src/scottish_budget_data/pipeline.py +++ b/src/scottish_budget_data/pipeline.py @@ -18,7 +18,6 @@ DistributionalImpactCalculator, MetricsCalculator, TwoChildLimitCalculator, - WinnersLosersCalculator, ) from .reforms import ReformDefinition, get_scottish_budget_reforms @@ -73,7 +72,6 @@ def generate_all_data( # Initialize calculators budgetary_calc = BudgetaryImpactCalculator(years=years) distributional_calc = DistributionalImpactCalculator() - winners_losers_calc = WinnersLosersCalculator() metrics_calc = MetricsCalculator() local_authority_calc = LocalAuthorityCalculator() @@ -102,7 +100,6 @@ def generate_all_data( # Aggregate results all_budgetary = [] all_distributional = [] - all_winners_losers = [] all_metrics = [] all_local_authorities = [] @@ -128,17 +125,11 @@ def generate_all_data( print(f" Year {year}...") # Distributional - distributional, decile_df = distributional_calc.calculate( + distributional = distributional_calc.calculate( reform.id, reform.name, year ) all_distributional.extend(distributional) - # Winners/losers - winners_losers = winners_losers_calc.calculate( - decile_df, reform.id, reform.name, year - ) - all_winners_losers.extend(winners_losers) - # Summary metrics (poverty) metrics = metrics_calc.calculate( baseline, reformed, reform.id, reform.name, year @@ -158,7 +149,6 @@ def generate_all_data( results = { "budgetary_impact": pd.DataFrame(all_budgetary), "distributional_impact": pd.DataFrame(all_distributional), - "winners_losers": pd.DataFrame(all_winners_losers), "metrics": pd.DataFrame(all_metrics), "local_authorities": pd.DataFrame(all_local_authorities), } diff --git a/src/utils/reformConfig.js b/src/utils/reformConfig.js index 842dd50..38cc976 100644 --- a/src/utils/reformConfig.js +++ b/src/utils/reformConfig.js @@ -22,7 +22,7 @@ export const REFORMS = [ id: "income_tax_intermediate_uplift", name: "Intermediate rate threshold uplift", description: "Intermediate rate threshold raised from £27,492 to £29,527 (+7.4%)", - color: "#14B8A6", // Teal 500 + color: "#0F766E", // Teal 700 type: "positive", }, // Income tax threshold freezes (cost households - revenue raising) @@ -30,21 +30,21 @@ export const REFORMS = [ id: "higher_rate_freeze", name: "Higher rate threshold freeze", description: "Higher rate threshold frozen at £43,662 until 2028-29", - color: "#F97316", // Orange 500 + color: "#78350F", // Amber 900 type: "negative", }, { id: "advanced_rate_freeze", name: "Advanced rate threshold freeze", description: "Advanced rate threshold frozen at £75,000 until 2028-29", - color: "#FB923C", // Orange 400 + color: "#92400E", // Amber 800 type: "negative", }, { id: "top_rate_freeze", name: "Top rate threshold freeze", description: "Top rate threshold frozen at £125,140 until 2028-29", - color: "#FDBA74", // Orange 300 + color: "#B45309", // Amber 700 type: "negative", }, // Scottish Child Payment @@ -52,14 +52,14 @@ export const REFORMS = [ id: "scp_inflation", name: "SCP inflation adjustment", description: "Scottish Child Payment uprated from £27.15 to £28.20/week", - color: "#2DD4BF", // Teal 400 + color: "#14B8A6", // Teal 500 type: "positive", }, { id: "scp_baby_boost", name: "SCP Premium for under-ones", description: "Scottish Child Payment raised to £40/week for babies under 1", - color: "#5EEAD4", // Teal 300 + color: "#2DD4BF", // Teal 400 type: "positive", }, ]; diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py index fdd30ab..ca7ecb7 100644 --- a/tests/test_data_validation.py +++ b/tests/test_data_validation.py @@ -20,12 +20,6 @@ def distributional_impact(): return pd.read_csv(DATA_DIR / "distributional_impact.csv") -@pytest.fixture -def winners_losers(): - """Load winners/losers data.""" - return pd.read_csv(DATA_DIR / "winners_losers.csv") - - @pytest.fixture def metrics(): """Load metrics data.""" @@ -164,16 +158,6 @@ def test_distributional_impact_has_all_deciles(distributional_impact): assert deciles == expected -def test_winners_losers_percentages_sum_to_100(winners_losers): - """Test winners/losers percentages sum to approximately 100%.""" - # Data format: metric column has winners_pct, losers_pct, unchanged_pct - for (reform_id, year), group in winners_losers.groupby(["reform_id", "year"]): - # Pivot to get percentages as columns - metrics = group.set_index("metric")["value"] - total = metrics.get("winners_pct", 0) + metrics.get("losers_pct", 0) + metrics.get("unchanged_pct", 0) - assert 99 < total < 101, f"Reform {reform_id} year {year}: percentages sum to {total}%, not ~100%" - - def test_all_data_files_exist(): """Test that all expected data files exist.""" expected_files = [ @@ -181,7 +165,6 @@ def test_all_data_files_exist(): "constituency.csv", "distributional_impact.csv", "metrics.csv", - "winners_losers.csv", ] for filename in expected_files: