Skip to content

Commit 4dc8e9a

Browse files
authored
Merge pull request #536 from PolicyEngine/fix/report-visual-fixes
Various visual fixes and improvements
2 parents ca53581 + 2ef3b44 commit 4dc8e9a

File tree

87 files changed

+2610
-210541
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+2610
-210541
lines changed

.claude/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"enabledPlugins": {
3+
"pr-review-toolkit@claude-plugins-official": true
4+
}
5+
}

.claude/skills/chart-standards.md

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
# Chart and Visualization Standards
2+
3+
This skill ensures consistent chart styling across PolicyEngine visualizations using Plotly.js via `react-plotly.js`.
4+
5+
## Required Imports
6+
7+
```tsx
8+
import type { Layout } from 'plotly.js';
9+
import Plot from 'react-plotly.js';
10+
import { colors } from '@/designTokens/colors';
11+
import { spacing } from '@/designTokens/spacing';
12+
import { ChartContainer } from '@/components/ChartContainer';
13+
import { DEFAULT_CHART_CONFIG, getClampedChartHeight } from '@/utils/chartUtils';
14+
```
15+
16+
## Chart Container Pattern
17+
18+
Always wrap charts in `ChartContainer` for consistent styling:
19+
20+
```tsx
21+
<ChartContainer title={getChartTitle()} onDownloadCsv={handleDownloadCsv}>
22+
<Stack gap={spacing.sm}>
23+
<Plot
24+
data={chartData}
25+
layout={layout}
26+
config={DEFAULT_CHART_CONFIG}
27+
style={{ width: '100%', height: chartHeight }}
28+
/>
29+
{description}
30+
</Stack>
31+
</ChartContainer>
32+
```
33+
34+
## Color Semantics
35+
36+
### Bar/Data Colors
37+
| Scenario | Color Token | Hex |
38+
|----------|-------------|-----|
39+
| Positive change (gains, winners) | `colors.primary[500]` | `#319795` (teal) |
40+
| Negative change (losses, losers) | `colors.gray[600]` | `#4B5563` |
41+
| Neutral/baseline | `colors.gray[300]` | `#D1D5DB` |
42+
| Error/warning states | `colors.error` | `#EF4444` |
43+
| Success states | `colors.success` | `#22C55E` |
44+
45+
### Dynamic Color Assignment
46+
```tsx
47+
marker: {
48+
color: yArray.map((value) => (value < 0 ? colors.gray[600] : colors.primary[500])),
49+
}
50+
```
51+
52+
### Multi-Series Colors
53+
Use the design system's series palette:
54+
```tsx
55+
import { chartColors } from '@policyengine/design-system/charts';
56+
57+
// Series order: teal, blue, dark teal, dark blue, gray
58+
chartColors.series[0] // Primary teal
59+
chartColors.series[1] // Blue accent
60+
chartColors.series[2] // Dark teal
61+
chartColors.series[3] // Dark blue
62+
chartColors.series[4] // Gray
63+
```
64+
65+
## Chart Configuration
66+
67+
### Default Config (Always Use)
68+
```tsx
69+
import { DEFAULT_CHART_CONFIG } from '@/utils/chartUtils';
70+
71+
config={{
72+
...DEFAULT_CHART_CONFIG,
73+
locale: localeCode(countryId), // For currency/number formatting
74+
}}
75+
```
76+
77+
This provides:
78+
- `displayModeBar: false` - Hides Plotly toolbar
79+
- `responsive: true` - Enables responsive sizing
80+
81+
### Responsive Height
82+
```tsx
83+
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
84+
import { getClampedChartHeight } from '@/utils/chartUtils';
85+
86+
const mobile = useMediaQuery('(max-width: 768px)');
87+
const { height: viewportHeight } = useViewportSize();
88+
const chartHeight = getClampedChartHeight(viewportHeight, mobile);
89+
90+
// Use in style
91+
style={{ width: '100%', height: chartHeight }}
92+
```
93+
94+
## Standard Layout Properties
95+
96+
### Base Layout
97+
```tsx
98+
const layout = {
99+
xaxis: {
100+
title: { text: 'X Axis Label' },
101+
fixedrange: true, // Disable zoom/pan
102+
},
103+
yaxis: {
104+
title: { text: 'Y Axis Label' },
105+
tickprefix: currencySymbol(countryId), // For currency
106+
tickformat: ',.0f', // Number format
107+
fixedrange: true,
108+
},
109+
showlegend: false, // Or configure legend position
110+
margin: {
111+
t: 0, // Top (usually 0, title is outside chart)
112+
b: 80, // Bottom (for x-axis labels)
113+
l: 60, // Left (for y-axis labels)
114+
r: 20, // Right
115+
},
116+
} as Partial<Layout>;
117+
```
118+
119+
### Text on Bars (Uniform Text)
120+
```tsx
121+
uniformtext: {
122+
mode: 'hide' as const, // Hide text that doesn't fit
123+
minsize: mobile ? 4 : 8,
124+
},
125+
```
126+
127+
### Legend Position (When Needed)
128+
```tsx
129+
legend: {
130+
orientation: 'h',
131+
yanchor: 'bottom',
132+
y: 1.02,
133+
xanchor: 'right',
134+
x: 1,
135+
},
136+
```
137+
138+
## Hover Templates
139+
140+
Use custom hover templates for rich tooltips:
141+
142+
```tsx
143+
{
144+
customdata: xArray.map((x, i) => hoverMessage(x, yArray[i])),
145+
hovertemplate: '<b>Decile %{x}</b><br><br>%{customdata}<extra></extra>',
146+
}
147+
```
148+
149+
The `<extra></extra>` hides the trace name box.
150+
151+
## Chart Title Pattern
152+
153+
Generate dynamic titles that describe the reform's impact:
154+
155+
```tsx
156+
const getChartTitle = () => {
157+
const change = calculateChange();
158+
const signTerm = change > 0 ? 'increase' : 'decrease';
159+
160+
if (change === 0) {
161+
return `This reform would have no effect on ${metric}`;
162+
}
163+
return `This reform would ${signTerm} ${metric} by ${formatValue(change)}`;
164+
};
165+
```
166+
167+
## CSV Export Pattern
168+
169+
Always provide CSV download functionality:
170+
171+
```tsx
172+
import { downloadCsv } from '@/utils/chartUtils';
173+
174+
const handleDownloadCsv = () => {
175+
const csvData = Object.entries(dataObject).map(([key, value]) => [
176+
`Label ${key}`,
177+
value.toString(),
178+
]);
179+
downloadCsv(csvData, 'descriptive-filename.csv');
180+
};
181+
```
182+
183+
## Description Pattern
184+
185+
Add explanatory text below charts:
186+
187+
```tsx
188+
const description = (
189+
<Text size="sm" c="dimmed">
190+
PolicyEngine sorts households into ten equally-populated groups...
191+
</Text>
192+
);
193+
```
194+
195+
## Complete Chart Component Template
196+
197+
```tsx
198+
import type { Layout } from 'plotly.js';
199+
import Plot from 'react-plotly.js';
200+
import { Stack, Text } from '@mantine/core';
201+
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
202+
import { ChartContainer } from '@/components/ChartContainer';
203+
import { colors } from '@/designTokens/colors';
204+
import { spacing } from '@/designTokens/spacing';
205+
import { DEFAULT_CHART_CONFIG, downloadCsv, getClampedChartHeight } from '@/utils/chartUtils';
206+
207+
interface Props {
208+
output: OutputType;
209+
}
210+
211+
export default function ChartSubPage({ output }: Props) {
212+
const mobile = useMediaQuery('(max-width: 768px)');
213+
const countryId = useCurrentCountry();
214+
const { height: viewportHeight } = useViewportSize();
215+
const chartHeight = getClampedChartHeight(viewportHeight, mobile);
216+
217+
// Extract and prepare data
218+
const xArray = Object.keys(output.data);
219+
const yArray = Object.values(output.data);
220+
221+
// Generate title
222+
const getChartTitle = () => {
223+
// Dynamic title based on data
224+
};
225+
226+
// CSV export
227+
const handleDownloadCsv = () => {
228+
const csvData = xArray.map((x, i) => [x, yArray[i].toString()]);
229+
downloadCsv(csvData, 'chart-name.csv');
230+
};
231+
232+
// Chart data
233+
const chartData = [{
234+
x: xArray,
235+
y: yArray,
236+
type: 'bar' as const,
237+
marker: {
238+
color: yArray.map((v) => (v < 0 ? colors.gray[600] : colors.primary[500])),
239+
},
240+
}];
241+
242+
// Layout
243+
const layout = {
244+
xaxis: { title: { text: 'X Label' }, fixedrange: true },
245+
yaxis: { title: { text: 'Y Label' }, fixedrange: true },
246+
showlegend: false,
247+
margin: { t: 0, b: 80, l: 60, r: 20 },
248+
} as Partial<Layout>;
249+
250+
return (
251+
<ChartContainer title={getChartTitle()} onDownloadCsv={handleDownloadCsv}>
252+
<Stack gap={spacing.sm}>
253+
<Plot
254+
data={chartData}
255+
layout={layout}
256+
config={DEFAULT_CHART_CONFIG}
257+
style={{ width: '100%', height: chartHeight }}
258+
/>
259+
<Text size="sm" c="dimmed">
260+
Explanatory description...
261+
</Text>
262+
</Stack>
263+
</ChartContainer>
264+
);
265+
}
266+
```
267+
268+
## Watermark (For Research/Blog Charts)
269+
270+
When charts need PolicyEngine branding (research posts, exported images):
271+
272+
```tsx
273+
import { chartLogo } from '@policyengine/design-system/charts';
274+
275+
layout: {
276+
images: [chartLogo],
277+
}
278+
```
279+
280+
Logo source: `/assets/logos/policyengine/teal-square.png`
281+
282+
## Anti-Patterns
283+
284+
### Never Do This
285+
```tsx
286+
// WRONG - Hardcoded colors
287+
marker: { color: '#319795' }
288+
marker: { color: value > 0 ? 'green' : 'red' }
289+
290+
// WRONG - Hardcoded dimensions
291+
style={{ height: 500 }}
292+
293+
// WRONG - Missing config
294+
<Plot data={data} layout={layout} />
295+
296+
// WRONG - No container
297+
<Plot ... /> // Charts should be in ChartContainer
298+
```
299+
300+
### Always Do This
301+
```tsx
302+
// CORRECT - Token colors
303+
marker: { color: colors.primary[500] }
304+
marker: { color: value < 0 ? colors.gray[600] : colors.primary[500] }
305+
306+
// CORRECT - Responsive height
307+
style={{ height: chartHeight }}
308+
309+
// CORRECT - With config
310+
<Plot data={data} layout={layout} config={DEFAULT_CHART_CONFIG} />
311+
312+
// CORRECT - With container
313+
<ChartContainer title={title} onDownloadCsv={handleCsv}>
314+
<Plot ... />
315+
</ChartContainer>
316+
```

0 commit comments

Comments
 (0)