Skip to content

Commit 372f23d

Browse files
authored
Merge pull request #102 from PolicyEngine/daphnehansell/ui-polish
Add ESI/NYC inputs, improve heatmap hover, and UI polish
2 parents 3b131b5 + 6c604d9 commit 372f23d

8 files changed

Lines changed: 428 additions & 152 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ __pycache__/
1212
env/
1313
venv/
1414

15+
# Vite
16+
.vite/
17+
1518
# IDE
1619
.idea/
1720
.vscode/

src/App.jsx

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
22
import InputForm from "./components/InputForm";
33
import ResultsDisplay from "./components/ResultsDisplay";
44
import { getCategorizedPrograms, getHeatmapData, DEFAULT_YEAR } from "./api";
5+
import { formatCurrency } from "./utils";
56

67
// URL state helpers
78
function encodeToHash(formData) {
@@ -23,6 +24,8 @@ function encodeToHash(formData) {
2324
.join(","),
2425
);
2526
}
27+
if (formData.esiStatus?.head) p.set("he", "1");
28+
if (formData.esiStatus?.spouse) p.set("se", "1");
2629
if (formData.year && formData.year !== DEFAULT_YEAR) {
2730
p.set("year", formData.year);
2831
}
@@ -45,7 +48,7 @@ function decodeFromHash() {
4548
})
4649
: [];
4750
return {
48-
stateCode: p.get("state"),
51+
stateCode: p.get("state") === "NY" && p.get("nyc") === "1" ? "NYC" : p.get("state"),
4952
headIncome: Number(p.get("head")),
5053
spouseIncome: Number(p.get("spouse") || 0),
5154
headAge: Number(p.get("ha") || 40),
@@ -58,6 +61,10 @@ function decodeFromHash() {
5861
head: p.get("hp") === "1",
5962
spouse: p.get("sp") === "1",
6063
},
64+
esiStatus: {
65+
head: p.get("he") === "1",
66+
spouse: p.get("se") === "1",
67+
},
6168
children,
6269
year: p.get("year") || DEFAULT_YEAR,
6370
};
@@ -76,6 +83,7 @@ export default function App() {
7683
const [valentine, setValentine] = useState(false);
7784
const [showConfetti, setShowConfetti] = useState(false);
7885
const [externalIncomes, setExternalIncomes] = useState(null);
86+
const [sidebarOpen, setSidebarOpen] = useState(true);
7987
const initialValues = useRef(decodeFromHash());
8088
const didAutoCalc = useRef(false);
8189

@@ -119,9 +127,11 @@ export default function App() {
119127
window.history.replaceState(null, "", `#${encodeToHash(data)}`);
120128

121129
const {
122-
stateCode, headIncome, spouseIncome, headAge, spouseAge,
123-
children, disabilityStatus, pregnancyStatus, year,
130+
headIncome, spouseIncome, headAge, spouseAge,
131+
children, disabilityStatus, pregnancyStatus, esiStatus, year,
124132
} = data;
133+
const stateCode = data.stateCode === "NYC" ? "NY" : data.stateCode;
134+
const inNYC = data.stateCode === "NYC";
125135

126136
setLoading(true);
127137
setError(null);
@@ -132,6 +142,7 @@ export default function App() {
132142
const result = await getCategorizedPrograms(
133143
stateCode, headIncome, spouseIncome, children,
134144
disabilityStatus, year, pregnancyStatus, headAge, spouseAge,
145+
esiStatus, inNYC,
135146
);
136147
setResults(result);
137148
setLoading(false);
@@ -141,6 +152,7 @@ export default function App() {
141152
const heatmap = await getHeatmapData(
142153
stateCode, children, disabilityStatus, year,
143154
pregnancyStatus, headIncome, spouseIncome, headAge, spouseAge,
155+
esiStatus, inNYC,
144156
);
145157
setHeatmapData(heatmap);
146158
} catch (e) {
@@ -191,20 +203,30 @@ export default function App() {
191203
? "Will tying the knot cost you? Find out this Valentine's Day."
192204
: "Evaluate marriage penalties and bonuses."}
193205
</p>
194-
<p className="powered-by">
195-
Powered by{" "}
196-
<a href="https://github.com/policyengine/policyengine-us" target="_blank" rel="noreferrer">policyengine-us</a>.
197-
</p>
198206
</header>
199207

200208
<div className="app-layout">
201-
<aside className="app-sidebar">
202-
<InputForm
203-
onCalculate={handleCalculate}
204-
loading={loading}
205-
initialValues={initialValues.current}
206-
externalIncomes={externalIncomes}
207-
/>
209+
<aside className={`app-sidebar ${results ? "has-results" : ""} ${sidebarOpen ? "sidebar-open" : ""}`}>
210+
{results && (
211+
<button
212+
type="button"
213+
className="sidebar-toggle"
214+
onClick={() => setSidebarOpen((v) => !v)}
215+
>
216+
<span className="sidebar-toggle-summary">
217+
{formData?.stateCode} &middot; {formatCurrency(formData?.headIncome ?? 0)} &amp; {formatCurrency(formData?.spouseIncome ?? 0)}
218+
</span>
219+
<span className="sidebar-toggle-arrow">{sidebarOpen ? "\u25B2" : "\u25BC"}</span>
220+
</button>
221+
)}
222+
<div className="sidebar-collapsible">
223+
<InputForm
224+
onCalculate={(data) => { setSidebarOpen(false); handleCalculate(data); }}
225+
loading={loading}
226+
initialValues={initialValues.current}
227+
externalIncomes={externalIncomes}
228+
/>
229+
</div>
208230
</aside>
209231

210232
<main className="app-main">
@@ -231,6 +253,7 @@ export default function App() {
231253
spouseIncome={formData?.spouseIncome ?? 0}
232254
valentine={valentine}
233255
onCellClick={handleCellClick}
256+
esiStatus={formData?.esiStatus}
234257
/>
235258
)}
236259
</main>

src/api.js

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export function createSituation(
6767
pregnancyStatus = {},
6868
headAge = DEFAULT_AGE,
6969
spouseAge = DEFAULT_AGE,
70+
esiStatus = {},
71+
inNYC = false,
7072
) {
7173
const members = ["you"];
7274
const maritalUnitMembers = ["you"];
@@ -77,6 +79,7 @@ export function createSituation(
7779
employment_income: { [year]: headIncome },
7880
is_disabled: { [year]: disabilityStatus.head || false },
7981
is_pregnant: { [year]: pregnancyStatus.head || false },
82+
...(esiStatus.head ? { has_esi: { [year]: true } } : {}),
8083
},
8184
};
8285

@@ -86,6 +89,7 @@ export function createSituation(
8689
employment_income: { [year]: spouseIncome },
8790
is_disabled: { [year]: disabilityStatus.spouse || false },
8891
is_pregnant: { [year]: pregnancyStatus.spouse || false },
92+
...(esiStatus.spouse ? { has_esi: { [year]: true } } : {}),
8993
};
9094
members.push("your partner");
9195
maritalUnitMembers.push("your partner");
@@ -119,6 +123,7 @@ export function createSituation(
119123
"your household": {
120124
members: [...members],
121125
state_name: { [year]: stateCode },
126+
...(inNYC ? { in_nyc: { [year]: true } } : {}),
122127
},
123128
},
124129
};
@@ -296,6 +301,8 @@ export async function getPrograms(
296301
pregnancyStatus = {},
297302
headAge = DEFAULT_AGE,
298303
spouseAge = DEFAULT_AGE,
304+
esiStatus = {},
305+
inNYC = false,
299306
) {
300307
const situation = createSituation(
301308
stateCode,
@@ -307,6 +314,8 @@ export async function getPrograms(
307314
pregnancyStatus,
308315
headAge,
309316
spouseAge,
317+
esiStatus,
318+
inNYC,
310319
);
311320
addOutputVariables(situation, year, stateCode);
312321

@@ -351,6 +360,8 @@ export async function getCategorizedPrograms(
351360
pregnancyStatus = {},
352361
headAge = DEFAULT_AGE,
353362
spouseAge = DEFAULT_AGE,
363+
esiStatus = {},
364+
inNYC = false,
354365
) {
355366
const [married, headSingle, spouseSingle] = await Promise.all([
356367
getPrograms(
@@ -363,6 +374,8 @@ export async function getCategorizedPrograms(
363374
pregnancyStatus,
364375
headAge,
365376
spouseAge,
377+
esiStatus,
378+
inNYC,
366379
),
367380
getPrograms(
368381
stateCode,
@@ -373,6 +386,9 @@ export async function getCategorizedPrograms(
373386
year,
374387
{ head: pregnancyStatus.head || false },
375388
headAge,
389+
DEFAULT_AGE,
390+
{ head: esiStatus.head || false },
391+
inNYC,
376392
),
377393
getPrograms(
378394
stateCode,
@@ -383,6 +399,9 @@ export async function getCategorizedPrograms(
383399
year,
384400
{ head: pregnancyStatus.spouse || false },
385401
spouseAge,
402+
DEFAULT_AGE,
403+
{ head: esiStatus.spouse || false },
404+
inNYC,
386405
),
387406
]);
388407

@@ -401,13 +420,15 @@ export async function getHeatmapData(
401420
spouseIncome = 0,
402421
headAge = DEFAULT_AGE,
403422
spouseAge = DEFAULT_AGE,
423+
esiStatus = {},
424+
inNYC = false,
404425
) {
405426
const rawMax = Math.max(80000, headIncome, spouseIncome);
406427
const step = Math.ceil(rawMax / 32 / 2500) * 2500;
407428
const maxIncome = step * 32;
408429
const count = 33;
409430

410-
function buildHeatmapSituation(includeSpouse, childrenList, disability, pregnancy, hAge, sAge) {
431+
function buildHeatmapSituation(includeSpouse, childrenList, disability, pregnancy, hAge, sAge, esi) {
411432
const situation = createSituation(
412433
stateCode,
413434
maxIncome,
@@ -418,6 +439,8 @@ export async function getHeatmapData(
418439
pregnancy,
419440
hAge,
420441
sAge,
442+
esi || {},
443+
inNYC,
421444
);
422445
addOutputVariables(situation, year, stateCode);
423446

@@ -436,15 +459,17 @@ export async function getHeatmapData(
436459
}
437460

438461
const marriedSituation = buildHeatmapSituation(
439-
true, children, disabilityStatus, pregnancyStatus, headAge, spouseAge,
462+
true, children, disabilityStatus, pregnancyStatus, headAge, spouseAge, esiStatus,
440463
);
441464
const headSingleSituation = buildHeatmapSituation(
442465
false, children, disabilityStatus,
443-
{ head: pregnancyStatus.head || false }, headAge,
466+
{ head: pregnancyStatus.head || false }, headAge, undefined,
467+
{ head: esiStatus.head || false },
444468
);
445469
const spouseSingleSituation = buildHeatmapSituation(
446470
false, [], { head: disabilityStatus.spouse || false },
447-
{ head: pregnancyStatus.spouse || false }, spouseAge,
471+
{ head: pregnancyStatus.spouse || false }, spouseAge, undefined,
472+
{ head: esiStatus.spouse || false },
448473
);
449474

450475
const [marriedResult, headResult, spouseResult] = await Promise.all([
@@ -504,17 +529,24 @@ export async function getHeatmapData(
504529
// Transpose: API returns head-major (row=head, col=spouse) but Plotly
505530
// z[row][col] maps to y[row],x[col] and x=head, y=spouse, so we need
506531
// row=spouse, col=head.
507-
return deltaGrid[0].map((_, col) =>
532+
const transposed = deltaGrid[0].map((_, col) =>
508533
deltaGrid.map((row) => row[col]),
509534
);
535+
return { grid: transposed, headLine: headFlat, spouseLine: spouseFlat };
510536
}
511537

538+
const headLines = {};
539+
const spouseLines = {};
540+
512541
for (let v = 0; v < gridVars.length; v++) {
513542
const varName = gridVars[v];
514-
const transposed = buildDeltaGrid(
543+
const { grid: transposed, headLine, spouseLine } = buildDeltaGrid(
515544
marriedData[varName], headData[varName], spouseData[varName],
516545
);
517546

547+
headLines[tabNames[v]] = headLine;
548+
spouseLines[tabNames[v]] = spouseLine;
549+
518550
if (varName === "household_tax_before_refundable_credits") {
519551
grids[tabNames[v]] = transposed.map((row) => row.map((val) => -val));
520552
} else {
@@ -528,8 +560,14 @@ export async function getHeatmapData(
528560
grids["Federal Credits"] = totalCreditsGrid.map((row, i) =>
529561
row.map((val, j) => val - stateCreditsGrid[i][j]),
530562
);
563+
headLines["Federal Credits"] = (headLines["Refundable Tax Credits"] || []).map(
564+
(v, i) => v - (headLines["State Credits"]?.[i] || 0),
565+
);
566+
spouseLines["Federal Credits"] = (spouseLines["Refundable Tax Credits"] || []).map(
567+
(v, i) => v - (spouseLines["State Credits"]?.[i] || 0),
568+
);
531569

532-
return { grids, maxIncome, count, programData, stateCreditEntries };
570+
return { grids, maxIncome, count, programData, stateCreditEntries, headLines, spouseLines };
533571
}
534572

535573
export function buildCellResults(programData, headIdx, spouseIdx, count, stateCreditEntries) {

src/components/Heatmap.jsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const VALENTINE_SCALE = [
1717
[1, "#BE185D"],
1818
];
1919

20-
export default function Heatmap({ grid, headIncome, spouseIncome, valentine, maxIncome = 80000, count = 33, markerDelta = null, fullscreen = false, onCellClick, selectedCell, label = "Net Change" }) {
20+
export default function Heatmap({ grid, headIncome, spouseIncome, valentine, maxIncome = 80000, count = 33, markerDelta = null, fullscreen = false, onCellClick, selectedCell, label = "Net Change", headLine, spouseLine }) {
2121
if (!grid || grid.length === 0) {
2222
return <p className="loading">No heatmap data available.</p>;
2323
}
@@ -59,6 +59,17 @@ export default function Heatmap({ grid, headIncome, spouseIncome, valentine, max
5959
const cellVal = grid[yi]?.[xi] ?? 0;
6060
const onDark = absMax > 0 && Math.abs(cellVal) / absMax > 0.45;
6161

62+
// Build customdata for hover: [notMarried, married] per cell
63+
const hasBeforeAfter = headLine && spouseLine && headLine.length === count && spouseLine.length === count;
64+
const customdata = hasBeforeAfter
65+
? grid.map((row, spouseIdx) =>
66+
row.map((delta, headIdx) => {
67+
const notMarried = (headLine[headIdx] || 0) + (spouseLine[spouseIdx] || 0);
68+
return [notMarried, notMarried + delta];
69+
}),
70+
)
71+
: null;
72+
6273
function handleClick(data) {
6374
if (!onCellClick || !data.points || data.points.length === 0) return;
6475
const point = data.points[0];
@@ -84,6 +95,7 @@ export default function Heatmap({ grid, headIncome, spouseIncome, valentine, max
8495
zmax: zMax,
8596
xgap: 1,
8697
ygap: 1,
98+
...(customdata ? { customdata } : {}),
8799
colorbar: {
88100
title: { text: `Change in ${label}`, side: "right", font: { size: 12 } },
89101
tickprefix: "$",
@@ -92,8 +104,9 @@ export default function Heatmap({ grid, headIncome, spouseIncome, valentine, max
92104
outlinewidth: 0,
93105
xpad: 15,
94106
},
95-
hovertemplate:
96-
`Your Income: %{x}<br>Spouse Income: %{y}<br>Change in ${label}: %{z:$,.0f}<extra></extra>`,
107+
hovertemplate: customdata
108+
? `Your income: %{x}<br>Partner's income: %{y}<br><br>Not married: %{customdata[0]:$,.0f}<br>Married: %{customdata[1]:$,.0f}<br><b>Change: %{z:$,.0f}</b><extra></extra>`
109+
: `Your income: %{x}<br>Partner's income: %{y}<br>Change in ${label}: %{z:$,.0f}<extra></extra>`,
97110
},
98111
// Marker — adapt colors to cell brightness
99112
{
@@ -110,21 +123,28 @@ export default function Heatmap({ grid, headIncome, spouseIncome, valentine, max
110123
text: [valentine ? "You \u2764" : "You"],
111124
textposition: "top center",
112125
textfont: { size: 11, color: onDark ? "white" : "#1E293B", family: "-apple-system, BlinkMacSystemFont, sans-serif" },
113-
name: "Your situation",
114-
hovertemplate: `Your situation<br>Your Income: ${markerX}<br>Spouse Income: ${markerY}<br>Change in ${label}: $${Math.round(markerVal).toLocaleString()}<extra></extra>`,
126+
name: "",
127+
hovertemplate: (() => {
128+
if (hasBeforeAfter) {
129+
const notMarried = (headLine[xi] || 0) + (spouseLine[yi] || 0);
130+
const married = notMarried + markerVal;
131+
return `Your income: ${markerX}<br>Partner's income: ${markerY}<br><br>Not married: $${Math.round(notMarried).toLocaleString()}<br>Married: $${Math.round(married).toLocaleString()}<br><b>Change: $${Math.round(markerVal).toLocaleString()}</b><extra></extra>`;
132+
}
133+
return `Your income: ${markerX}<br>Partner's income: ${markerY}<br>Change: $${Math.round(markerVal).toLocaleString()}<extra></extra>`;
134+
})(),
115135
showlegend: false,
116136
},
117137
]}
118138
layout={{
119139
xaxis: {
120-
title: { text: "Your Income", standoff: 10 },
140+
title: { text: "Your income", standoff: 10 },
121141
side: "bottom",
122142
tickangle: 0,
123143
tickvals: visibleTicks,
124144
ticktext: visibleTicks,
125145
},
126146
yaxis: {
127-
title: { text: "Partner's Income", standoff: 10 },
147+
title: { text: "Partner's income", standoff: 10 },
128148
tickvals: visibleTicks,
129149
ticktext: visibleTicks,
130150
},

0 commit comments

Comments
 (0)