
Display Quantitative Information
- 12 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
display-quantitative-information is a Claude Code skill in the AI & Agent Building category.
- display-quantitative-information
- AI & Agent Building
- AI-coding skill
Display Quantitative Information by the numbers
- 12 all-time installs (skills.sh)
- Ranked #11,614 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill display-quantitative-informationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 12 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Display Quantitative Information
Use this skill to help an agent create, critique, redesign, audit, or explain quantitative displays that let people reason from evidence. The default standard is truthfulness first, comparison power second, and visual economy third. Minimalism is not the goal; clear quantitative reasoning is.
Activation boundaries
Use this skill for chart choice, data visualization code, dashboard review, statistical/scientific figures, misleading graphics, graph redesign, tables used as evidence, uncertainty displays, small multiples, map-based quantitative displays, or user language such as data-ink, chartjunk, graphical integrity, lie factor, Tufte, visual evidence, publication-ready figure, or dashboard critique.
Do not use it for decorative illustration, infographics with no measured quantities, brand-only design, slide aesthetics without data, or general data wrangling unless a display or visual explanation is part of the task.
Working loop
1. Name the viewer's task: lookup, comparison, trend, relationship, distribution, part-to-whole, geography, uncertainty, monitoring, explanation, or persuasion. 2. Inspect the data structure: grain, units, denominators, time order, grouping, spatial structure, missingness, transformations, sample size, and uncertainty. 3. Choose the display from the task and data, not from a favorite chart type. Use references/display-selection.md when the choice is not obvious. 4. Audit integrity before aesthetics: baselines, scales, proportionality, encodings, transformations, omitted context, denominators, uncertainty, source, and accessibility. Use references/integrity-audit.md or scripts/audit_visual_display.py for structured specs. 5. Redesign by improving the intended comparison. Remove distracting marks, but keep labels, notes, reference lines, captions, and structure when they help interpretation. 6. Deliver the artifact requested: chart, code, SVG, design spec, critique, dashboard review, or short recommendation. Put the highest-impact fix first.
Mode-specific guidance
For a quick critique, answer in plain language: what works, what may mislead, and the most valuable fix. Do not bury an integrity problem under cosmetic advice.
For a redesign, state the proposed display form, encodings, scale choices, labels, annotations, and integrity safeguards. Explain choices in terms of the viewer's comparison or decision.
For chart creation, produce the chart or code when tools permit. Add a short final check covering units, scale, baseline, source/context, uncertainty, and accessibility.
For dashboards, review the workflow first: whether panels answer a coherent decision, share compatible time windows and denominators, and show trends or distributions rather than isolated decorative KPIs.
For scientific figures, prioritize sample size, units, conditions, uncertainty, transformations, calibration, and comparison across panels. Avoid summary-only bars when raw observations or intervals are central.
Non-negotiables
Never trade a misleading chart for a cleaner misleading chart. Preserve or restore units, source, definitions, sample size, denominators, relevant uncertainty, and methodological context whenever they affect interpretation.
Do not mechanically apply slogans. Data-ink discipline is an editing principle, not a license to remove explanation. A legend, gridline, note, or reference band is useful when it reduces ambiguity or supports comparison.
Avoid formulaic critique language. Across multiple outputs, vary the opener, recommendation order, examples, and vocabulary according to the dataset and audience. Use references/language-and-variation.md or scripts/fingerprint_text.py for long/batch deliverables.
Reference map
Read only the files needed for the task.
references/principles.md— core Tufte-informed judgment standards.references/display-selection.md— display choices by task and data structure.references/integrity-audit.md— distortion, lie factors, baselines, context, and uncertainty.references/redesign-workflow.md— practical redesign and handoff sequence.references/chart-spec.md— structured chart-spec fields and examples.references/accessibility-and-output.md— contrast, color, labels, alt text, and code/output defaults.references/language-and-variation.md— anti-fingerprint guidance for critiques.references/rubric.md— scoring rubric for reviews.references/examples.md— worked patterns; adapt, do not copy.
Scripts and assets
Scripts are optional but useful when the user supplies data or a structured chart spec. They are non-interactive and print structured output.
scripts/suggest_display.py --csv data.csv --goal auto --format markdowninspects a CSV and recommends display families.scripts/audit_visual_display.py --spec chart.json --format markdownaudits a JSON chart spec.scripts/lie_factor.py --data-before 18 --data-after 27.5 --visual-before 0.6 --visual-after 5.3computes visual distortion.scripts/contrast_check.py --foreground '#333333' --background '#ffffff' --format markdownchecks text/color contrast.scripts/render_chart_svg.py --csv data.csv --x month --y defect_rate --chart line --group line --output chart.svgcreates a simple, honest SVG chart for handoff or review.scripts/fingerprint_text.py --input draft.md --format markdownflags repeated stock visualization language.
Assets:
assets/chart-spec-template.json— starting point for structured audits.assets/critique-note-template.md— flexible critique handoff note.assets/chart-handoff-template.md— compact implementation spec.
Completion check
Before finalizing, verify that the response names the analytical task, preserves units/context, justifies the display form, checks for misleading scales or encodings, and gives at least one concrete improvement to comparison, integrity, or accessibility.
Chart Handoff
Question answered:
Display form:
Data grain and variables:
Encodings:
Scale and baseline choices:
Labels, annotations, and source notes:
Uncertainty/sample-size treatment:
Accessibility checks:
Implementation notes:
{
"title": "Monthly defect rate by production line",
"purpose": "Compare defect-rate trends across production lines and identify changes after a process change.",
"chart_type": "line",
"data_points": 120,
"variables": [
"month",
"line",
"defect_rate_per_1000_units"
],
"data_grain": "one row per production line per month",
"encodings": {
"x": "month",
"y": "defect_rate_per_1000_units",
"color": "line",
"size": null,
"shape": null,
"facet": null,
"label": null
},
"axes": {
"x_labeled": true,
"y_labeled": true,
"units_shown": true,
"y_zero_baseline": null,
"y_scale": "linear",
"same_scale_across_panels": true,
"axis_break": false,
"dual_axis": false
},
"labels": {
"direct_labels": true,
"legend": false,
"source_shown": true,
"definitions_shown": true,
"annotations": [
"process change, 2025-09"
]
},
"context": {
"denominator_shown": true,
"uncertainty_shown": null,
"sample_size_shown": null,
"comparison_baseline_shown": true,
"time_range_justified": true
},
"design": {
"grid": "light",
"decorations": [],
"three_d": false,
"background_image": false,
"uses_area_for_quantity": false,
"uses_volume_for_quantity": false,
"color_count": 4,
"color_is_only_identifier": false
},
"integrity_measurements": {
"data_effect_percent": null,
"visual_effect_percent": null
},
"medium": {
"destination": "report",
"color_required": false,
"minimum_text_size_pt": 9
}
}
Quantitative Display Critique Note
Purpose of the display:
What works:
Highest-impact issue:
Why it matters for interpretation:
Recommended redesign:
Integrity safeguards to preserve:
Accessibility/context notes:
Language variation reminder: use the chart's own variables, units, and audience. Do not paste stock phrasing.
{
"skill_name": "display-quantitative-information",
"version": "2.0.0",
"evals": [
{
"id": "audit-misleading-bar-spec",
"prompt": "Audit evals/files/bad_chart_spec.json and tell me the highest-priority fixes before redesigning the chart.",
"files": [
"evals/files/bad_chart_spec.json"
],
"expected_output": "A prioritized critique that flags the truncated/non-zero bar baseline, 3D/decorative effects, missing labels/units/source, hidden uncertainty, and lie-factor distortion.",
"assertions": [
"Uses or is consistent with scripts/audit_visual_display.py output",
"Identifies the non-zero baseline for a bar chart as a severe integrity issue",
"Mentions missing units or labels",
"Provides a redesign direction, not just a list of flaws"
]
},
{
"id": "suggest-factory-defect-display",
"prompt": "I have evals/files/factory_defects.csv. What chart should I make to compare defect rates across lines over time?",
"files": [
"evals/files/factory_defects.csv"
],
"expected_output": "A recommendation for a time-series line chart or small multiples using defect_rate_per_1000_units, with units, common scales, and direct labels or panel labels.",
"assertions": [
"Chooses defect_rate_per_1000_units rather than raw defects or units inspected as the main y variable",
"Recommends a line chart or small multiples for ordered months",
"Mentions common scales or direct labels for comparing lines"
]
},
{
"id": "anscombe-summary-warning",
"prompt": "Using evals/files/anscombe.csv, explain why summary statistics are not enough and what display to use.",
"files": [
"evals/files/anscombe.csv"
],
"expected_output": "A recommendation to plot faceted scatterplots or small multiples and inspect outliers, shape, nonlinearity, and leverage rather than relying on summaries alone.",
"assertions": [
"Recommends scatterplots or small multiples/facets",
"Mentions that similar summaries can hide different structure",
"Identifies outliers, nonlinearity, or leverage as visible concerns"
]
},
{
"id": "render-simple-svg",
"prompt": "Create a quick SVG chart from evals/files/factory_defects.csv showing defect_rate_per_1000_units by month grouped by line.",
"files": [
"evals/files/factory_defects.csv"
],
"expected_output": "An SVG line chart artifact or code path using month on x, defect_rate_per_1000_units on y, and line as the group, with axis labels and a reminder to verify source/uncertainty.",
"assertions": [
"Uses scripts/render_chart_svg.py or equivalent SVG generation",
"Uses month for x and defect_rate_per_1000_units for y",
"Includes labels or title describing defect rate"
]
},
{
"id": "contrast-palette-check",
"prompt": "Check evals/files/contrast_palette.json and tell me which chart text colors are unsafe.",
"files": [
"evals/files/contrast_palette.json"
],
"expected_output": "A contrast check that passes dark gray on white and flags #bbbbbb on white as low contrast for normal text.",
"assertions": [
"Uses or matches scripts/contrast_check.py output",
"Flags the low contrast annotation pair",
"Does not treat contrast as the only accessibility concern"
]
},
{
"id": "language-varies-in-batch-critiques",
"prompt": "Review five chart critiques for repeated visualization boilerplate and suggest how to make them less generic.",
"expected_output": "A response that checks for repeated stock phrases and recommends varying evidence-specific language, opener structure, and chart-specific mechanisms.",
"assertions": [
"Mentions repeated stock visualization phrases as a risk",
"Recommends using chart-specific variables, units, audience, or mechanism",
"Does not simply ban useful technical terms"
]
}
]
}
dataset,x,y
I,10,8.04
I,8,6.95
I,13,7.58
I,9,8.81
I,11,8.33
I,14,9.96
I,6,7.24
I,4,4.26
I,12,10.84
I,7,4.82
I,5,5.68
II,10,9.14
II,8,8.14
II,13,8.74
II,9,8.77
II,11,9.26
II,14,8.1
II,6,6.13
II,4,3.1
II,12,9.13
II,7,7.26
II,5,4.74
III,10,7.46
III,8,6.77
III,13,12.74
III,9,7.11
III,11,7.81
III,14,8.84
III,6,6.08
III,4,5.39
III,12,8.15
III,7,6.42
III,5,5.73
IV,8,6.58
IV,8,5.76
IV,8,7.71
IV,8,8.84
IV,8,8.47
IV,8,7.04
IV,8,5.25
IV,19,12.5
IV,8,5.56
IV,8,7.91
IV,8,6.89
{
"title": "Sales are exploding",
"purpose": "Persuade executives that sales jumped dramatically.",
"chart_type": "bar",
"data_points": 4,
"variables": [
"quarter",
"sales_millions"
],
"data_grain": "one row per quarter",
"encodings": {
"x": "quarter",
"y": "sales_millions",
"color": "quarter"
},
"axes": {
"x_labeled": false,
"y_labeled": false,
"units_shown": false,
"y_zero_baseline": false,
"y_scale": "linear",
"same_scale_across_panels": true,
"axis_break": false,
"dual_axis": false
},
"labels": {
"direct_labels": false,
"legend": true,
"source_shown": false,
"definitions_shown": false,
"annotations": []
},
"context": {
"denominator_shown": null,
"uncertainty_shown": false,
"sample_size_shown": false,
"comparison_baseline_shown": false,
"time_range_justified": false
},
"design": {
"grid": "none",
"decorations": [
"drop shadow",
"gradient background"
],
"three_d": true,
"background_image": true,
"uses_area_for_quantity": false,
"uses_volume_for_quantity": false,
"color_count": 10,
"color_is_only_identifier": true
},
"integrity_measurements": {
"data_effect_percent": 8,
"visual_effect_percent": 80
},
"medium": {
"destination": "slide",
"color_required": true,
"minimum_text_size_pt": 6
}
}
{
"pairs": [
{
"label": "axis text",
"foreground": "#333333",
"background": "#ffffff"
},
{
"label": "low contrast annotation",
"foreground": "#bbbbbb",
"background": "#ffffff"
}
]
}
month,line,units_inspected,defects,defect_rate_per_1000_units
2025-01,A,1180,14,11.9
2025-02,A,1205,11,9.1
2025-03,A,1220,10,8.2
2025-01,B,990,20,20.2
2025-02,B,1010,19,18.8
2025-03,B,1050,16,15.2
2025-01,C,1400,12,8.6
2025-02,C,1425,14,9.8
2025-03,C,1410,13,9.2
[
{
"query": "Can you critique this dashboard and tell me which chart is misleading?",
"should_trigger": true
},
{
"query": "I uploaded a CSV of defect rates; help me choose a plot and make it publication-ready.",
"should_trigger": true
},
{
"query": "This bar chart starts at 92\u2014how bad is that?",
"should_trigger": true
},
{
"query": "Turn these forecast intervals into a clear figure with uncertainty bands.",
"should_trigger": true
},
{
"query": "Make a better visual argument from these numbers for my report.",
"should_trigger": true
},
{
"query": "Use Tufte principles to redesign this chart.",
"should_trigger": true
},
{
"query": "My boss wants a map of raw case counts by county\u2014is that okay?",
"should_trigger": true
},
{
"query": "Generate a quick SVG chart from this CSV.",
"should_trigger": true
},
{
"query": "Can you check whether these chart label colors have enough contrast?",
"should_trigger": true
},
{
"query": "Make me a decorative hero image for a website landing page.",
"should_trigger": false
},
{
"query": "Clean this CSV and upload rows to Postgres.",
"should_trigger": false
},
{
"query": "Write a Python function to parse dates in this file.",
"should_trigger": false
},
{
"query": "Design a logo for my materials science group.",
"should_trigger": false
},
{
"query": "Summarize this paper in bullet points.",
"should_trigger": false
},
{
"query": "Fix the formulas in my Excel budget spreadsheet.",
"should_trigger": false
},
{
"query": "Make my slide deck prettier but don't change any charts.",
"should_trigger": false
}
]
Accessibility and Output
Accessibility is part of quantitative integrity: a display that only some viewers can read is losing evidence.
Labels and text
Use readable type, human labels, and units. Avoid vertical text, tiny legends, all-caps labels, and unexplained abbreviations. Direct labels often beat legends because they reduce lookup effort.
For dense scientific or dashboard displays, use captions and annotations to explain what the viewer should compare, not to decorate the chart.
Color
Do not make color the only carrier of meaning. Add direct labels, markers, line styles, faceting, or text cues. Use ordered lightness for ordered data and distinct hues for unordered groups. Avoid red/green-only distinctions for critical states.
Use scripts/contrast_check.py for text or important annotation colors. Contrast alone does not guarantee good design, but poor contrast is a concrete failure.
Alt text and captions
For user-facing deliverables, include concise alt text when charts are exported to documents, slides, or web pages. Good alt text names the chart type, variables, key comparison, and important caveat. It should not repeat every data point if the data table is available.
Code-generation defaults
When generating chart code:
- Inspect the data before plotting.
- Preserve units and use explicit labels.
- Prefer direct labels or clear legends.
- Avoid 3D, perspective, bevels, decorative backgrounds, and pictorial scaling.
- Use zero baselines for bar lengths; use dots or intervals when zero is not meaningful.
- Show uncertainty for estimates or forecasts.
- Add source/caption notes when the chart stands alone.
- Save outputs with descriptive names.
If a quick, dependency-free artifact is useful, use scripts/render_chart_svg.py to create simple SVG bars, dots, lines, or scatterplots.
Chart Specification
Use a structured spec when auditing, handing off, or asking another tool to render a chart. assets/chart-spec-template.json is compatible with scripts/audit_visual_display.py.
Minimum fields
title: working title, not marketing copy.purpose: viewer task or decision.chart_type: bar, dot, line, scatter, map, table, small_multiples, etc.data_points: approximate count of visible observations.variables: field names and derived variables.data_grain: one row means what.encodings: x, y, color, size, shape, facet, label.axes: labels, units, zero baseline, scale type, axis breaks, dual axes, panel scales.labels: direct labels, legend, source, definitions, annotations.context: denominator, uncertainty, sample size, comparison baseline, time range.design: grid, decorations, 3D, area/volume encodings, color count, color-only identification.integrity_measurements: data and visual percent effects where measurable.medium: destination, color reliance, minimum text size.
Spec-first workflow
1. Draft the spec before rendering. 2. Run python3 scripts/audit_visual_display.py --spec spec.json --format markdown. 3. Fix severe and warning findings. 4. Render the chart or provide implementation guidance. 5. Add final notes for source, units, uncertainty, and accessibility.
Do not overfit to the template
The template is a forcing function, not a bureaucracy. Skip irrelevant fields in the user-facing answer, but keep enough structure that another agent or developer can implement the display without guessing.
Display Selection
Choose the display from the viewer's task and the data structure. The same dataset may need a table for lookup, a line chart for change, a distribution plot for spread, and small multiples for repeated comparison.
Exact lookup
Use a table or compact text-table when exact values matter more than visual pattern. Sort by the meaningful quantity unless natural order matters. Round to useful precision, align numbers, and use light grouping rules. Add sparklines or inline bars only when pattern comparison matters.
Magnitude comparison across categories
Use bars when length from zero is the natural encoding and absolute magnitude matters. Start the quantitative axis at zero. Use horizontal bars for long labels and sort by value unless the domain order is meaningful.
Use dot plots when there are many categories, deviations from a reference are central, intervals sit beside estimates, or zero is not a meaningful anchor.
Change over ordered time
Use lines for trends, cycles, seasonality, rates, and event effects. Keep time on the horizontal axis unless the domain strongly suggests otherwise. Annotate meaningful events directly. Use small multiples or indexed lines when many series overlap or start from different levels.
Relationship between quantitative variables
Use scatterplots for association, clusters, outliers, heteroscedasticity, or model fit. Add transparency, density contours, hex bins, or sampling when overplotting hides structure. Label transformations and smoothing methods.
Use connected scatterplots only when temporal or path order is central and the audience can follow the path.
Distribution and uncertainty
Use histograms, dot plots, strip plots, box plots, violins, density plots, ridgelines, interval plots, or fan charts depending on audience and task. Show raw observations when sample size is small enough and distribution shape matters. Label what intervals mean.
Avoid bar charts of means when the decision depends on spread, overlap, skew, outliers, or sample size.
Part-to-whole
Pie charts are narrow-use tools, not forbidden tools. They work best for a simple part-to-whole impression with very few slices and clear labels. When ranking, exact comparison, many categories, or small differences matter, use a sorted bar, table, grouped categories, or a 100 percent bar with care.
Geography
Use maps when location is explanatory or decision-relevant. Prefer rates, ratios, or normalized values when raw counts mostly reflect population, area, tests, stores, or exposure. If the task is ranking or exact comparison, include a companion table or dot plot.
Repeated comparisons
Use small multiples when the same comparison repeats across groups, time periods, places, scenarios, samples, models, or conditions. Keep scales common when cross-panel magnitude matters. Change scales only when within-panel shape is the point, and label that choice.
Multivariate structure
Add variables only when they answer the question. Options include facets, color, shape, size, heatmaps, paired panels, and parallel coordinates. Position and length are usually easier to compare than area, angle, hue, or volume.
Common substitutions, with conditions
- Replace a dual-axis chart with indexed lines, small multiples, or a scatterplot when the dual axes imply a relationship that depends on arbitrary scaling.
- Replace a stacked bar with small multiples, grouped bars, or a heatmap when viewers need to compare middle segments.
- Replace a gauge with a bullet chart, trend, or table when the gauge wastes space or lacks history.
- Replace pictograms with bars, dots, or tables when pictorial scale distorts magnitude.
- Keep the original form when it fits the viewer's task better than a fashionable substitute.
Examples
These are worked patterns, not fixed prose. Adapt vocabulary, structure, and recommendation order to the user's data and audience.
Truncated bar chart
Pattern: start with the integrity issue, then offer a chart form that matches the comparison.
Direction: If bars make a modest difference look large because length is read from a hidden zero point, rebuild with a zero baseline for absolute comparison. If the task is deviation around a target, use a dot plot or difference chart with the target labeled explicitly.
Dual-axis time series
Pattern: separate the scaling problem from the substantive claim.
Direction: A dual-axis chart can manufacture or hide correlation because the two axes are independently scaled. Use indexed lines when relative change is the task, small multiples for separate histories, or a scatterplot if the relationship between variables is the question.
Map of raw counts
Pattern: ask whether geography or population explains the pattern.
Direction: If high values mostly identify high-population places, use a rate or exposure-normalized measure. Pair the map with a ranked dot plot when precise comparison matters.
Bar chart of means
Pattern: check whether the summary hides spread.
Direction: When group overlap, skew, sample size, or outliers matter, show raw points with intervals, a dot/box hybrid, or another distribution display. Keep the mean only if it answers the decision.
Dense dashboard
Pattern: redesign around decisions, not widgets.
Direction: Group panels by workflow, align time windows, show denominators, and put alerts next to trend/distribution evidence. Remove decorative gauges when a bullet, line, or table would show status plus context.
Anscombe-like data
Pattern: do not let summary statistics replace plots.
Direction: When multiple datasets share similar means, variances, or correlations, show scatterplots or residuals to reveal shape, outliers, nonlinearity, and leverage.
Integrity Audit
Run integrity checks before cosmetic redesign. The most dangerous charts often look polished.
Core questions
1. What is the claim or comparison? 2. What is the numerical effect in the data? 3. What visual effect does the graphic show? 4. Are the visual and numerical effects proportional? 5. What context would change the interpretation: denominator, sample size, time window, baseline, uncertainty, source, transformation, inflation, population, exposure, or filter?
Baselines and scales
Bars and columns encode magnitude by length, so a visible zero baseline is usually required. If the meaningful story is deviation from a reference, use a dot plot, interval plot, slope chart, or difference chart instead of a truncated bar.
Line charts and scatterplots do not always require zero baselines, but the chosen range must not exaggerate trivial variation or hide important variation. Label transformations, log scales, index bases, breaks, and independent panel scales.
Lie factor
When a visual effect can be measured, compute:
lie factor = visual percent change / data percent change
Values near 1 are visually proportional. Large departures suggest exaggeration or understatement. Opposite signs indicate a severe reversal of meaning. Use scripts/lie_factor.py or the lie-factor fields in assets/chart-spec-template.json.
Area, volume, and pictorial scaling
If a one-dimensional quantity is encoded with area or volume, check whether the displayed area or volume is actually proportional to the number. Scaling both height and width by the data value squares the apparent effect; scaling height, width, and depth cubes it.
Prefer position or length for precise quantitative comparison unless the area is the actual measured phenomenon.
Denominators and exposure
Counts often need denominators. Crime, defects, cases, emissions, failures, claims, clicks, and incidents may require rates per population, units, time, exposure, tests, or opportunities. Maps are especially prone to confusing raw counts with population density.
Context and omitted comparisons
A graphic may distort by omission. Check whether the time window, comparison group, benchmark, seasonal cycle, historical range, or uncertainty range is too narrow for the claim. Add context where it changes interpretation.
Uncertainty and sample size
Show uncertainty when data are estimates, samples, forecasts, simulations, measurements, model outputs, or small-n comparisons. Use intervals, bands, raw points, sample sizes, sensitivity panels, or notes. Label what the interval means.
Checklist for existing charts
- Axis labels and units are visible.
- Baseline and scale choices match the encoding.
- Same scales are used across comparable panels, unless clearly labeled otherwise.
- Source, sample, filters, denominator, and time window are available.
- Visual dimensions are proportional to numerical quantities.
- Aggregation does not hide distribution or outliers central to the claim.
- Color is not the only way to identify important groups.
- Decorative marks do not overpower evidence.
Language and Variation
Agents are prone to producing the same critique shape repeatedly. That makes reviews feel generic and can obscure the actual diagnosis.
Vary by evidence
Each critique should sound like it came from the chart being reviewed. Mention the actual variables, units, denominator, visual encoding, scale choice, audience, and medium. Domain vocabulary should replace generic phrasing, not decorate it.
A public-health chart may need incidence, population denominator, confidence interval, age adjustment, and reporting lag. A materials or imaging figure may need scale bars, calibration, voxels, segmentation, sample preparation, registration, and measurement uncertainty.
Avoid fixed replacement habits
Do not recommend the same substitute for every flaw. A pie chart does not always become a bar chart; a multi-line chart does not always become small multiples; a legend does not always become direct labels. Diagnose the task first, then choose.
Good recommendations have a mechanism: "use an interval dot plot because the zero baseline is not meaningful and the uncertainty intervals need comparison" is stronger than "replace bars with dots."
Make examples non-pasteable
Examples in this skill show patterns, not text to reuse. When adapting an example, change the domain nouns, numerical context, chart type, evidence, and recommendation order.
Response-shape variation
Pick a shape that fits the task:
- A fast verdict can be two short paragraphs.
- A detailed critique can use sections for integrity, comparison, and redesign.
- A dashboard review can group issues by workflow or panel family.
- A code handoff can start with a spec and end with checks.
- A teaching answer can explain the design principle first, then the example.
Avoid using the same numbered structure in every output unless the user asked for standardization.
Stock-phrase check
For long or repeated deliverables, run:
python3 scripts/fingerprint_text.py --input draft.md --format markdownTreat warnings as prompts to revise, not automatic failures. Technical terms can repeat when they are genuinely needed.
Principles
This reference defines the judgment standard for quantitative displays. Use display-selection.md for chart-type choices and integrity-audit.md for distortion checks.
Graphical excellence
A strong quantitative display communicates complex evidence with clarity, precision, and efficiency. It helps viewers compare, inspect, and reason more effectively than the raw table alone, while preserving enough context to judge the evidence honestly.
Good displays often do several things at once:
- reveal comparisons, variation, outliers, clusters, trends, uncertainty, or spatial structure;
- align visual magnitude with numerical magnitude;
- integrate words, numbers, and pictures close to the evidence;
- support both overview and detail when the medium allows;
- organize complexity rather than deleting useful complexity.
Evidence before decoration
Ask what each mark does. A mark earns its place when it encodes data, labels data, supports comparison, shows context, explains uncertainty, improves accessibility, or guides the viewer through a difficult comparison.
A mark is suspect when it mainly decorates, dramatizes, repeats information, or pulls attention away from the evidence. This is not a ban on all non-data ink: light gridlines, axes, source notes, captions, thresholds, uncertainty legends, panel labels, and annotations are often essential.
Integrity before elegance
A calm-looking chart can still be dishonest. Check whether scales, baselines, transformations, encodings, denominators, time windows, filters, and uncertainty support the claim. Do not rely on printed numbers to rescue a misleading visual encoding.
Comparison is the central act
Most visualization problems are comparison problems. Improve a display by making the intended comparison easier: align common baselines, sort deliberately, use common scales, facet repeated structures, label directly, add meaningful reference lines, or show paired differences.
Density is not clutter
Low-density charts can waste attention and hide context. High-density displays can be clear when they are ordered, consistently scaled, annotated, and visually quiet. Remove useless marks before removing useful data.
Useful density can come from raw observations, small multiples, compact points, context bands, reference series, marginal distributions, or carefully integrated labels. Do not inflate a few numbers into a grand graphic when a sentence or small table would be clearer.
Words, numbers, and pictures belong together
A quantitative display is not just geometry. Titles frame the question, labels carry units and definitions, annotations explain events or anomalies, and captions preserve context. Integrate these near the data rather than forcing viewers to decode a remote legend or footnote.
Mechanism over slogans
Do not merely say "chartjunk" or "show the data." Explain the mechanism: what the current design makes hard to compare, why that matters, and how the proposed change improves inference.
Redesign Workflow
Use this when the user asks to improve an existing chart, dashboard, or figure.
Sequence
1. State the viewer's task in one sentence. 2. Name the current obstacle: misleading scale, wrong display form, missing denominator, hidden uncertainty, weak comparison, excess decoration, or too little data. 3. Preserve or restore context: units, source, denominator, sample size, transformations, uncertainty, filters, and definitions. 4. Choose the display form using display-selection.md if needed. 5. Improve comparison through ordering, alignment, common scales, direct labels, reference lines, paired differences, or facets. 6. Remove marks that compete with evidence. Keep non-data marks that make interpretation easier. 7. Add annotations where they explain events, thresholds, outliers, definitions, or methods. 8. Stop when the next edit would polish rather than clarify.
Editing moves
Use only the moves that fit the case.
- Sort categories by value, change, domain order, or viewer workflow.
- Align comparable quantities on a common baseline.
- Use common scales across comparable panels.
- Replace remote legends with direct labels when labels fit near the data.
- Replace overplotted series with small multiples, context bands, or a highlighted focus series.
- Replace summary-only charts with raw observations plus summaries when distribution matters.
- Add reference ranges, targets, baselines, and event annotations tied to decisions.
- Lighten heavy grids, frames, backgrounds, bevels, shadows, and decorative fills.
- Make captions and annotations do explanatory work.
Dashboard-specific moves
Group panels by decision or workflow, not by data source. Replace isolated headline numbers with trends, targets, distributions, or prior periods. Make time windows and denominators consistent unless differences are deliberate and labeled. Put alerts next to the evidence that justifies them.
Scientific-figure moves
Show sample size, units, conditions, and uncertainty. Avoid summary bars when raw observations or intervals are central. Keep panel labels, captions, and methods-relevant transformations close to the data. Use consistent scales for comparable panels.
Handoff structure
For a redesign handoff, include:
- purpose: the question answered;
- current obstacle: the specific failure;
- proposed display: chart type and layout;
- encodings: fields mapped to position, length, color, size, shape, facets, labels;
- integrity safeguards: scale, baseline, units, source, denominator, uncertainty;
- annotation plan: events, thresholds, outliers, definitions;
- expected improvement: what comparison becomes easier or more honest.
Adapt the shape to the user's task; do not force this as a rigid template in every response.
Rubric
Use this scorecard for chart, map, dashboard, and statistical-figure reviews. It is for diagnosis and prioritization, not aesthetic policing.
Score each dimension from 0 to 5. A 5 is excellent, 3 is usable but improvable, 1 is seriously weak, and 0 is absent or actively misleading.
Dimensions
Analytical purpose: the display answers a clear quantitative question and the title or caption frames that question honestly.
Graphical integrity: scales, baselines, transformations, encodings, denominators, uncertainty, and source support a truthful reading.
Data visibility: relevant observations, variation, outliers, distribution, or uncertainty are visible rather than hidden behind excessive aggregation.
Comparison power: ordering, alignment, scales, grouping, and labels make the intended comparison easy.
Economy of means: most marks encode data or useful structure; decoration and redundancy do not compete with evidence.
Annotation and context: labels, units, definitions, source, events, thresholds, and caveats appear close enough to the data to reduce ambiguity.
Accessibility and robustness: text is legible, color is not the only carrier of meaning, contrast is adequate, and the chart works in its intended medium.
Overall interpretation
30 to 35: strong display; focus on polish or publication constraints.
23 to 29: usable, with one or two high-impact fixes.
15 to 22: materially weak; redesign the comparison or restore missing context.
Below 15: likely misleading or ineffective; start from the analytical question and rebuild.
Prioritization
When time is short, fix issues in this order: misleading magnitude, missing denominators or uncertainty, wrong chart for the task, hidden data, weak comparison, accessibility, then cosmetic cleanup.
#!/usr/bin/env python3
"""Audit a structured chart specification for quantitative display risks."""
from __future__ import annotations
import argparse
import json
import math
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
TEMPLATE: Dict[str, Any] = {
"title": "Monthly defect rate by production line",
"purpose": "Compare defect-rate trends across production lines and identify changes after a process change.",
"chart_type": "line",
"data_points": 120,
"variables": ["month", "line", "defect_rate_per_1000_units"],
"data_grain": "one row per production line per month",
"encodings": {"x": "month", "y": "defect_rate_per_1000_units", "color": "line", "size": None, "shape": None, "facet": None, "label": None},
"axes": {"x_labeled": True, "y_labeled": True, "units_shown": True, "y_zero_baseline": None, "y_scale": "linear", "same_scale_across_panels": True, "axis_break": False, "dual_axis": False},
"labels": {"direct_labels": True, "legend": False, "source_shown": True, "definitions_shown": True, "annotations": ["process change, 2025-09"]},
"context": {"denominator_shown": True, "uncertainty_shown": None, "sample_size_shown": None, "comparison_baseline_shown": True, "time_range_justified": True},
"design": {"grid": "light", "decorations": [], "three_d": False, "background_image": False, "uses_area_for_quantity": False, "uses_volume_for_quantity": False, "color_count": 4, "color_is_only_identifier": False},
"integrity_measurements": {"data_effect_percent": None, "visual_effect_percent": None},
"medium": {"destination": "report", "color_required": False, "minimum_text_size_pt": 9},
}
@dataclass
class Finding:
severity: str
category: str
message: str
recommendation: str
SEVERITY_PENALTY = {"severe": 25, "warning": 10, "info": 3}
SEVERITY_RANK = {"severe": 0, "warning": 1, "info": 2}
def is_true(value: Any) -> bool:
return value is True or (isinstance(value, str) and value.strip().lower() in {"true", "yes", "y", "1"})
def is_false(value: Any) -> bool:
return value is False or (isinstance(value, str) and value.strip().lower() in {"false", "no", "n", "0"})
def is_unknown(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip().lower() in {"", "unknown", "n/a", "na", "none"})
def get(spec: Dict[str, Any], path: str, default: Any = None) -> Any:
cur: Any = spec
for part in path.split("."):
if not isinstance(cur, dict) or part not in cur:
return default
cur = cur[part]
return cur
def add(findings: List[Finding], severity: str, category: str, message: str, recommendation: str) -> None:
findings.append(Finding(severity, category, message, recommendation))
def norm_chart(spec: Dict[str, Any]) -> str:
return str(spec.get("chart_type", "")).strip().lower().replace(" ", "_").replace("-", "_")
def load_spec(path: Path) -> Dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
raise SystemExit(f"Error: spec file not found: {path}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Error: invalid JSON in {path}: {exc}")
if not isinstance(data, dict):
raise SystemExit("Error: chart spec must be a JSON object.")
return data
def audit(spec: Dict[str, Any]) -> Dict[str, Any]:
findings: List[Finding] = []
passed: List[str] = []
chart = norm_chart(spec)
axes = spec.get("axes") if isinstance(spec.get("axes"), dict) else {}
labels = spec.get("labels") if isinstance(spec.get("labels"), dict) else {}
context = spec.get("context") if isinstance(spec.get("context"), dict) else {}
design = spec.get("design") if isinstance(spec.get("design"), dict) else {}
medium = spec.get("medium") if isinstance(spec.get("medium"), dict) else {}
if not str(spec.get("purpose", "")).strip():
add(findings, "warning", "purpose", "No analytical purpose is stated.", "State the viewer's comparison or decision before choosing a display form.")
else:
passed.append("analytical purpose stated")
if chart in {"bar", "bar_chart", "column", "column_chart", "stacked_bar", "grouped_bar"}:
baseline = axes.get("y_zero_baseline")
if is_false(baseline) and not is_true(axes.get("axis_break")):
add(findings, "severe", "graphical integrity", "Bars encode magnitude by length but the quantitative axis is not marked as starting at zero.", "Start the quantitative axis at zero, or use a dot/difference plot if deviations are the point.")
elif is_true(baseline):
passed.append("bar/column baseline starts at zero")
if chart in {"pie", "donut", "doughnut"}:
slices = spec.get("slices") or spec.get("categories") or spec.get("data_points")
try:
if slices is not None and int(slices) > 5:
add(findings, "warning", "comparison", "The part-to-whole chart appears to have many slices.", "Use a sorted bar, table, or grouped categories when ranking or exact comparison matters.")
except (TypeError, ValueError):
pass
if chart in {"choropleth", "map", "symbol_map"} and is_false(context.get("denominator_shown")):
add(findings, "warning", "denominator", "The map lacks a visible denominator or exposure basis.", "Use rates or normalized values when raw counts mostly reflect population, area, tests, stores, or exposure.")
if is_true(axes.get("axis_break")):
add(findings, "warning", "scale", "The spec uses an axis break.", "Use an unbroken scale when possible; otherwise label the break prominently and explain why it is needed.")
if is_true(axes.get("dual_axis")):
add(findings, "warning", "scale", "The spec uses dual axes.", "Check whether the apparent relationship depends on arbitrary scaling; consider indexed series, small multiples, or a scatterplot.")
if is_false(axes.get("same_scale_across_panels")):
add(findings, "warning", "comparison", "Comparable panels are not marked as using the same scale.", "Use common scales when cross-panel magnitude matters, or label independent scaling clearly.")
if is_false(axes.get("x_labeled")):
add(findings, "warning", "labeling", "The x-axis is not labeled.", "Add an x-axis label or direct labels that make the horizontal variable unambiguous.")
if is_false(axes.get("y_labeled")):
add(findings, "warning", "labeling", "The y-axis is not labeled.", "Add a y-axis label with units.")
if is_false(axes.get("units_shown")):
add(findings, "warning", "labeling", "Units are not visible.", "Show units in axis labels, legends, annotations, or caption.")
elif is_true(axes.get("units_shown")):
passed.append("units shown")
if is_false(labels.get("source_shown")):
add(findings, "warning", "context", "The data source is not shown.", "Add a concise source note for decision, publication, or external-facing use.")
if is_false(labels.get("definitions_shown")):
add(findings, "info", "context", "Definitions are not shown.", "Define abbreviations, derived rates, filters, or inclusion rules when readers may not know them.")
uncertainty = context.get("uncertainty_shown")
if is_false(uncertainty):
add(findings, "warning", "uncertainty", "Uncertainty is hidden or marked absent.", "If these are estimates, forecasts, samples, simulations, or measurements, show intervals, bands, sample sizes, or caveats.")
elif is_unknown(uncertainty):
add(findings, "info", "uncertainty", "Uncertainty treatment is unspecified.", "If values are estimates, forecasts, samples, simulations, or measurements, state or show uncertainty.")
else:
passed.append("uncertainty treatment specified")
if is_false(context.get("sample_size_shown")):
add(findings, "info", "sample size", "Sample size is not shown.", "Show sample size when it affects reliability or comparison.")
if is_false(context.get("time_range_justified")):
add(findings, "warning", "context", "The time range is not justified.", "Include enough history or context for the claim, or state why the selected window is appropriate.")
if is_true(design.get("three_d")):
add(findings, "severe", "chartjunk", "The design uses 3D or perspective effects.", "Remove 3D unless the data are truly spatial and perspective is necessary.")
if is_true(design.get("background_image")):
add(findings, "warning", "chartjunk", "The chart uses a background image.", "Remove background imagery unless it is part of the data context, such as a map or instrument image.")
decorations = design.get("decorations") or []
if isinstance(decorations, str):
decorations = [decorations]
if decorations:
add(findings, "warning", "data-ink", "Decorative elements are listed: " + ", ".join(map(str, decorations)) + ".", "Remove decorations unless each one directly supports interpretation.")
if is_true(design.get("uses_area_for_quantity")):
add(findings, "warning", "encoding", "Area encodes quantity.", "Confirm displayed area is proportional to value; prefer position or length for precise comparison.")
if is_true(design.get("uses_volume_for_quantity")):
add(findings, "severe", "encoding", "Volume encodes quantity.", "Avoid volume encodings for non-spatial quantities.")
if is_true(design.get("color_is_only_identifier")):
add(findings, "warning", "accessibility", "Color is the only identifier for at least one grouping.", "Add direct labels, symbols, line styles, or panel labels.")
try:
if int(design.get("color_count", 0)) > 8:
add(findings, "info", "color", "The spec uses many colors.", "Check whether facets, grouping, or highlighting would reduce color lookup.")
except (TypeError, ValueError):
pass
try:
min_pt = float(medium.get("minimum_text_size_pt"))
if min_pt < 8:
add(findings, "warning", "accessibility", "Minimum text size is below 8 pt.", "Increase text size or simplify labels for the intended medium.")
except (TypeError, ValueError):
pass
lie_factor: Optional[float] = None
data_effect = get(spec, "integrity_measurements.data_effect_percent")
visual_effect = get(spec, "integrity_measurements.visual_effect_percent")
if data_effect is not None or visual_effect is not None:
try:
data_value = float(data_effect)
visual_value = float(visual_effect)
if math.isclose(data_value, 0.0):
add(findings, "warning", "lie factor", "Cannot compute lie factor because the data effect is zero.", "Use absolute differences or another proportionality check.")
else:
lie_factor = visual_value / data_value
if lie_factor < 0:
add(findings, "severe", "lie factor", f"Lie factor is {lie_factor:.3g}, implying opposite visual and numerical direction.", "Check the encoding; the visual direction should not reverse the numerical direction.")
elif not (0.95 <= lie_factor <= 1.05):
severity = "severe" if lie_factor < 0.67 or lie_factor > 1.5 else "warning"
add(findings, severity, "lie factor", f"Lie factor is {lie_factor:.3g}; visual change is not proportional to data change.", "Make visual dimensions proportional to numerical quantities or use a safer encoding.")
else:
passed.append("lie factor is near 1")
except (TypeError, ValueError):
add(findings, "info", "lie factor", "Lie factor inputs are incomplete or not numeric.", "Provide numeric data_effect_percent and visual_effect_percent if visual distortion can be measured.")
findings.sort(key=lambda f: (SEVERITY_RANK.get(f.severity, 9), f.category, f.message))
penalty = sum(SEVERITY_PENALTY.get(f.severity, 0) for f in findings)
score = max(0, 100 - penalty)
top_priorities = [asdict(f) for f in findings if f.severity in {"severe", "warning"}][:3]
return {
"title": spec.get("title"),
"chart_type": chart or None,
"score_0_100": score,
"lie_factor": lie_factor,
"findings": [asdict(f) for f in findings],
"top_priorities": top_priorities,
"passed_checks": passed,
}
def print_markdown(result: Dict[str, Any]) -> None:
print(f"Audit score: {result['score_0_100']}/100")
if result.get("lie_factor") is not None:
print(f"Lie factor: {result['lie_factor']:.3g}")
if result.get("top_priorities"):
print("\nTop priorities:")
for item in result["top_priorities"]:
print(f"- {item['severity'].upper()} — {item['category']}: {item['message']} Recommendation: {item['recommendation']}")
if result.get("findings"):
print("\nAll findings:")
for item in result["findings"]:
print(f"- {item['severity'].upper()} — {item['category']}: {item['message']} Recommendation: {item['recommendation']}")
else:
print("\nNo findings from the automated checklist. Human review is still required.")
if result.get("passed_checks"):
print("\nPassed checks:")
for item in result["passed_checks"]:
print(f"- {item}")
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Audit a JSON chart spec for quantitative-display integrity and clarity.")
parser.add_argument("--spec", type=Path, help="Path to JSON chart spec.")
parser.add_argument("--template", action="store_true", help="Print a JSON chart-spec template to stdout.")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
args = parser.parse_args(argv)
if args.template:
print(json.dumps(TEMPLATE, indent=2))
return 0
if not args.spec:
parser.error("provide --spec chart.json or --template")
result = audit(load_spec(args.spec))
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print_markdown(result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Check WCAG-style contrast ratios for chart labels and annotations."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$")
def parse_hex(color: str) -> Tuple[float, float, float]:
m = HEX_RE.match(color.strip())
if not m:
raise ValueError(f"invalid hex color: {color!r}; expected #RRGGBB")
raw = m.group(1)
return tuple(int(raw[i:i+2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
def linearize(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
def luminance(color: str) -> float:
r, g, b = parse_hex(color)
rl, gl, bl = linearize(r), linearize(g), linearize(b)
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl
def contrast_ratio(foreground: str, background: str) -> float:
l1, l2 = luminance(foreground), luminance(background)
hi, lo = max(l1, l2), min(l1, l2)
return (hi + 0.05) / (lo + 0.05)
def classify(ratio: float, large_text: bool = False) -> Dict[str, Any]:
aa_threshold = 3.0 if large_text else 4.5
aaa_threshold = 4.5 if large_text else 7.0
return {"passes_AA": ratio >= aa_threshold, "passes_AAA": ratio >= aaa_threshold, "AA_threshold": aa_threshold, "AAA_threshold": aaa_threshold}
def check_pair(fg: str, bg: str, label: str = "pair", large_text: bool = False) -> Dict[str, Any]:
ratio = contrast_ratio(fg, bg)
return {"label": label, "foreground": fg, "background": bg, "contrast_ratio": ratio, "large_text": large_text, **classify(ratio, large_text)}
def load_pairs(path: Path) -> List[Dict[str, Any]]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise SystemExit(f"Error: palette file not found: {path}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Error: invalid JSON in {path}: {exc}")
if isinstance(data, dict) and "pairs" in data:
pairs = data["pairs"]
elif isinstance(data, list):
pairs = data
else:
raise SystemExit("Error: palette JSON must be a list or an object with a 'pairs' list.")
if not isinstance(pairs, list):
raise SystemExit("Error: 'pairs' must be a list.")
return pairs
def print_markdown(results: List[Dict[str, Any]]) -> None:
for item in results:
status = "PASS" if item["passes_AA"] else "FAIL"
print(f"- {status} {item['label']}: {item['foreground']} on {item['background']} ratio {item['contrast_ratio']:.2f}:1 (AA threshold {item['AA_threshold']}:1)")
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Check color contrast for chart text, labels, and annotation colors.")
parser.add_argument("--foreground", help="Foreground/text color as #RRGGBB.")
parser.add_argument("--background", help="Background color as #RRGGBB.")
parser.add_argument("--label", default="pair", help="Label for a single color pair.")
parser.add_argument("--large-text", action="store_true", help="Use large-text contrast thresholds.")
parser.add_argument("--palette", type=Path, help="JSON file with pairs: [{label, foreground, background, large_text}].")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
args = parser.parse_args(argv)
try:
if args.palette:
results = [check_pair(str(p["foreground"]), str(p["background"]), str(p.get("label", "pair")), bool(p.get("large_text", False))) for p in load_pairs(args.palette)]
else:
if not args.foreground or not args.background:
parser.error("provide --foreground and --background, or --palette palette.json")
results = [check_pair(args.foreground, args.background, args.label, args.large_text)]
except (KeyError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
if args.format == "json":
print(json.dumps({"results": results}, indent=2))
else:
print_markdown(results)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Check a draft visualization critique for repetitive stock language."""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Dict, List, Optional
STOCK_PHRASES = [
"show the data",
"small multiples",
"chartjunk",
"data-ink",
"direct labels",
"zero baseline",
"graphical integrity",
"visual clutter",
"make comparison easier",
"avoid distortion",
"clearer and more honest",
]
def read_text(path: Optional[str]) -> str:
if not path or path == "-":
return sys.stdin.read()
try:
return Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
raise SystemExit(f"Error: input file not found: {path}")
def sentence_starts(text: str) -> Counter[str]:
sentences = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
starts: Counter[str] = Counter()
for sent in sentences:
words = re.findall(r"[A-Za-z][A-Za-z'-]*", sent.lower())
if len(words) >= 2:
starts[" ".join(words[:2])] += 1
return starts
def analyze(text: str) -> Dict[str, Any]:
lower = text.lower()
phrase_counts = {phrase: len(re.findall(re.escape(phrase), lower)) for phrase in STOCK_PHRASES}
phrase_counts = {k: v for k, v in phrase_counts.items() if v > 1}
starts = {k: v for k, v in sentence_starts(text).items() if v > 1}
warnings: List[str] = []
if phrase_counts:
warnings.append("Repeated stock visualization terms detected; keep them only where they diagnose a real issue.")
if starts:
warnings.append("Repeated sentence starts detected; vary opener shapes across critique items.")
return {"warnings": warnings, "repeated_stock_phrases": phrase_counts, "repeated_sentence_starts": starts, "character_count": len(text)}
def print_markdown(result: Dict[str, Any]) -> None:
if result["warnings"]:
print("Fingerprint warnings:")
for warning in result["warnings"]:
print(f"- {warning}")
else:
print("No major repetition warnings.")
if result["repeated_stock_phrases"]:
print("\nStock phrase counts:")
for phrase, count in sorted(result["repeated_stock_phrases"].items()):
print(f"- {phrase}: {count}")
if result["repeated_sentence_starts"]:
print("\nRepeated sentence starts:")
for start, count in sorted(result["repeated_sentence_starts"].items()):
print(f"- {start}: {count}")
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Check a draft visualization critique for repetitive stock language.")
parser.add_argument("--input", help="Text or Markdown file. Omit or use - for stdin.")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
args = parser.parse_args(argv)
result = analyze(read_text(args.input))
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print_markdown(result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Compute Tufte-style lie factor for a quantitative display."""
from __future__ import annotations
import argparse
import json
import math
import sys
from typing import Any, Dict, Optional
def pct_change(before: float, after: float) -> float:
if math.isclose(before, 0.0):
raise ValueError("before value is zero; percentage change is undefined")
return (after - before) / abs(before) * 100.0
def classify(lf: Optional[float]) -> str:
if lf is None:
return "not_computed"
if lf < 0:
return "severe_reverse_direction"
if 0.95 <= lf <= 1.05:
return "proportional"
if 0.67 <= lf < 0.95 or 1.05 < lf <= 1.5:
return "moderate_distortion"
return "severe_distortion"
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="Compute lie factor: visual effect divided by data effect.",
epilog=(
"Examples:\n"
" python3 scripts/lie_factor.py --data-effect 20 --visual-effect 60\n"
" python3 scripts/lie_factor.py --data-before 100 --data-after 120 "
"--visual-before 10 --visual-after 16 --format markdown"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--data-effect", type=float, help="Numerical percentage change in the data.")
parser.add_argument("--visual-effect", type=float, help="Percentage change in the perceived visual dimension.")
parser.add_argument("--data-before", type=float, help="Initial data value.")
parser.add_argument("--data-after", type=float, help="Final data value.")
parser.add_argument("--visual-before", type=float, help="Initial visual dimension, such as bar length, area, or symbol height.")
parser.add_argument("--visual-after", type=float, help="Final visual dimension.")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
args = parser.parse_args(argv)
try:
data_effect = args.data_effect
visual_effect = args.visual_effect
if data_effect is None:
if args.data_before is None or args.data_after is None:
parser.error("provide --data-effect or both --data-before and --data-after")
data_effect = pct_change(args.data_before, args.data_after)
if visual_effect is None:
if args.visual_before is None or args.visual_after is None:
parser.error("provide --visual-effect or both --visual-before and --visual-after")
visual_effect = pct_change(args.visual_before, args.visual_after)
if math.isclose(data_effect, 0.0):
raise ValueError("data effect is zero; lie factor is undefined")
lie_factor = visual_effect / data_effect
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
result: Dict[str, Any] = {
"data_effect_percent": data_effect,
"visual_effect_percent": visual_effect,
"lie_factor": lie_factor,
"classification": classify(lie_factor),
"interpretation": "values near 1 are proportional; values far from 1 suggest visual distortion",
}
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print(f"Lie factor: {lie_factor:.3g}")
print(f"Data effect: {data_effect:.3g}%")
print(f"Visual effect: {visual_effect:.3g}%")
print(f"Classification: {result['classification']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Render a simple, dependency-free SVG chart from CSV data.
This is intentionally modest: it creates reviewable first-pass bar, dot, line,
and scatter charts with labels and honest defaults. Use full plotting libraries
for publication-grade output when available.
"""
from __future__ import annotations
import argparse
import csv
import html
import json
import math
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
DATE_RE = re.compile(r"^(\d{4}-\d{1,2}-\d{1,2}|\d{4}-\d{1,2}|\d{1,2}/\d{1,2}/\d{2,4})$")
PALETTE = ["#222222", "#666666", "#999999", "#444444", "#777777", "#bbbbbb"]
def to_float(value: Any) -> Optional[float]:
try:
if value is None or str(value).strip() == "":
return None
return float(str(value).replace(",", ""))
except ValueError:
return None
def read_csv(path: Path) -> List[Dict[str, str]]:
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
except FileNotFoundError:
raise SystemExit(f"Error: CSV file not found: {path}")
except csv.Error as exc:
raise SystemExit(f"Error: could not parse CSV: {exc}")
if not rows:
raise SystemExit("Error: CSV has no data rows.")
return rows
def infer_chart(rows: List[Dict[str, str]], x: str, y: str, group: Optional[str]) -> str:
xs = [r.get(x, "") for r in rows]
x_numeric = sum(1 for v in xs if to_float(v) is not None) / max(1, len(xs)) > 0.85
x_date = sum(1 for v in xs if DATE_RE.match(str(v).strip())) / max(1, len(xs)) > 0.5
if x_date:
return "line"
if x_numeric:
return "scatter"
unique_x = len(set(xs))
return "bar" if unique_x <= 25 and not group else "dot"
def scale(value: float, domain_min: float, domain_max: float, range_min: float, range_max: float) -> float:
if math.isclose(domain_min, domain_max):
return (range_min + range_max) / 2
return range_min + (value - domain_min) / (domain_max - domain_min) * (range_max - range_min)
def nice_ticks(vmin: float, vmax: float, count: int = 5) -> List[float]:
if math.isclose(vmin, vmax):
return [vmin]
span = vmax - vmin
raw_step = span / max(1, count - 1)
mag = 10 ** math.floor(math.log10(abs(raw_step)))
norm = raw_step / mag
if norm <= 1:
step = 1 * mag
elif norm <= 2:
step = 2 * mag
elif norm <= 5:
step = 5 * mag
else:
step = 10 * mag
start = math.floor(vmin / step) * step
ticks = []
val = start
while val <= vmax + step * 0.5 and len(ticks) < 20:
if val >= vmin - step * 0.1:
ticks.append(0.0 if math.isclose(val, 0.0) else val)
val += step
return ticks
def fmt_num(v: float) -> str:
if abs(v) >= 1000 or (abs(v) < 0.01 and not math.isclose(v, 0)):
return f"{v:.2g}"
if math.isclose(v, round(v)):
return str(int(round(v)))
return f"{v:.3g}"
def aggregate(rows: List[Dict[str, str]], x: str, y: str, group: Optional[str]) -> List[Dict[str, Any]]:
acc: Dict[Tuple[str, str], List[float]] = defaultdict(list)
for r in rows:
yv = to_float(r.get(y))
if yv is None:
continue
xv = str(r.get(x, ""))
gv = str(r.get(group, "")) if group else ""
acc[(xv, gv)].append(yv)
data = []
for (xv, gv), vals in acc.items():
data.append({"x": xv, "group": gv, "y": sum(vals) / len(vals), "n": len(vals)})
return data
def svg_text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", extra: str = "") -> str:
return f'<text x="{x:.1f}" y="{y:.1f}" font-family="Arial, sans-serif" font-size="{size}" text-anchor="{anchor}" {extra}>{html.escape(text)}</text>'
def render(rows: List[Dict[str, str]], x: str, y: str, chart: str, group: Optional[str], title: str, width: int, height: int) -> Tuple[str, Dict[str, Any]]:
data = aggregate(rows, x, y, group)
if not data:
raise SystemExit("Error: no numeric y values found after parsing.")
if chart == "auto":
chart = infer_chart(rows, x, y, group)
if chart not in {"bar", "dot", "line", "scatter"}:
raise SystemExit("Error: --chart must be one of auto, bar, dot, line, scatter.")
margin = {"left": 72, "right": 32 if not group else 90, "top": 58, "bottom": 74}
plot_x0, plot_y0 = margin["left"], margin["top"]
plot_x1, plot_y1 = width - margin["right"], height - margin["bottom"]
plot_w, plot_h = plot_x1 - plot_x0, plot_y1 - plot_y0
yvals = [d["y"] for d in data]
if chart == "bar":
ymin, ymax = min(0, min(yvals)), max(0, max(yvals))
else:
pad = (max(yvals) - min(yvals)) * 0.08 or 1.0
ymin, ymax = min(yvals) - pad, max(yvals) + pad
if min(yvals) >= 0 and ymin < 0:
ymin = 0
ticks = nice_ticks(ymin, ymax)
if ticks:
ymin = min(ymin, min(ticks)); ymax = max(ymax, max(ticks))
elements = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">', '<rect width="100%" height="100%" fill="white"/>']
elements.append(svg_text(width/2, 26, title or f"{y} by {x}", size=16))
elements.append(svg_text(width/2, height-16, x, size=12))
elements.append(f'<text x="18" y="{height/2:.1f}" font-family="Arial, sans-serif" font-size="12" text-anchor="middle" transform="rotate(-90 18 {height/2:.1f})">{html.escape(y)}</text>')
# Grid and y axis labels
for t in ticks:
yy = scale(t, ymin, ymax, plot_y1, plot_y0)
elements.append(f'<line x1="{plot_x0}" y1="{yy:.1f}" x2="{plot_x1}" y2="{yy:.1f}" stroke="#dddddd" stroke-width="1"/>')
elements.append(svg_text(plot_x0-8, yy+4, fmt_num(t), size=10, anchor="end"))
elements.append(f'<line x1="{plot_x0}" y1="{plot_y0}" x2="{plot_x0}" y2="{plot_y1}" stroke="#333333" stroke-width="1"/>')
elements.append(f'<line x1="{plot_x0}" y1="{plot_y1}" x2="{plot_x1}" y2="{plot_y1}" stroke="#333333" stroke-width="1"/>')
warnings: List[str] = []
groups = sorted(set(d["group"] for d in data)) if group else [""]
group_color = {g: PALETTE[i % len(PALETTE)] for i, g in enumerate(groups)}
if chart in {"bar", "dot", "line"}:
xcats = sorted(set(d["x"] for d in data), key=lambda v: (not DATE_RE.match(str(v)), str(v)))
xpos = {cat: plot_x0 + (i + 0.5) * plot_w / max(1, len(xcats)) for i, cat in enumerate(xcats)}
# x labels, sampled if dense
step = max(1, math.ceil(len(xcats) / 12))
for i, cat in enumerate(xcats):
if i % step == 0 or i == len(xcats) - 1:
elements.append(svg_text(xpos[cat], plot_y1+18, str(cat), size=10, anchor="middle", extra='transform="rotate(35 {:.1f} {:.1f})"'.format(xpos[cat], plot_y1+18) if len(str(cat)) > 8 else ""))
if chart == "bar":
if group:
warnings.append("Grouped bars are simplified; consider dot plots or small multiples if many groups need comparison.")
bw = plot_w / max(1, len(xcats)) * 0.72 / max(1, len(groups))
zero_y = scale(0, ymin, ymax, plot_y1, plot_y0)
for d in data:
gi = groups.index(d["group"])
cx = xpos[d["x"]] - (len(groups)-1)*bw/2 + gi*bw
yy = scale(d["y"], ymin, ymax, plot_y1, plot_y0)
top, bottom = min(yy, zero_y), max(yy, zero_y)
elements.append(f'<rect x="{cx-bw/2:.1f}" y="{top:.1f}" width="{bw:.1f}" height="{max(1,bottom-top):.1f}" fill="{group_color[d["group"]]}"/>')
elif chart == "dot":
for d in data:
gi = groups.index(d["group"])
jitter = (gi - (len(groups)-1)/2) * 8
cx = xpos[d["x"]] + jitter
yy = scale(d["y"], ymin, ymax, plot_y1, plot_y0)
elements.append(f'<circle cx="{cx:.1f}" cy="{yy:.1f}" r="4" fill="{group_color[d["group"]]}"/>')
else: # line
for g in groups:
series = sorted([d for d in data if d["group"] == g], key=lambda d: xcats.index(d["x"]))
pts = [(xpos[d["x"]], scale(d["y"], ymin, ymax, plot_y1, plot_y0), d) for d in series]
path = " ".join(("M" if i == 0 else "L") + f" {px:.1f} {py:.1f}" for i, (px, py, _) in enumerate(pts))
elements.append(f'<path d="{path}" fill="none" stroke="{group_color[g]}" stroke-width="2"/>')
for px, py, _ in pts:
elements.append(f'<circle cx="{px:.1f}" cy="{py:.1f}" r="2.6" fill="{group_color[g]}"/>')
if group and pts:
px, py, _ = pts[-1]
elements.append(svg_text(px+6, py+4, str(g), size=10, anchor="start"))
else: # scatter
xnums: List[float] = []
points: List[Tuple[float, float, str]] = []
for r in rows:
xv = to_float(r.get(x)); yv = to_float(r.get(y))
if xv is None or yv is None:
continue
gv = str(r.get(group, "")) if group else ""
xnums.append(xv); points.append((xv, yv, gv))
if not points:
raise SystemExit("Error: scatter chart requires numeric x and y values.")
xmin, xmax = min(xnums), max(xnums)
xpad = (xmax-xmin)*0.08 or 1.0
xmin, xmax = xmin-xpad, xmax+xpad
xticks = nice_ticks(xmin, xmax)
for t in xticks:
xx = scale(t, xmin, xmax, plot_x0, plot_x1)
elements.append(f'<line x1="{xx:.1f}" y1="{plot_y0}" x2="{xx:.1f}" y2="{plot_y1}" stroke="#eeeeee" stroke-width="1"/>')
elements.append(svg_text(xx, plot_y1+18, fmt_num(t), size=10))
for xv, yv, gv in points:
xx = scale(xv, xmin, xmax, plot_x0, plot_x1)
yy = scale(yv, ymin, ymax, plot_y1, plot_y0)
elements.append(f'<circle cx="{xx:.1f}" cy="{yy:.1f}" r="3.5" fill="{group_color.get(gv, PALETTE[0])}" opacity="0.85"/>')
if group and chart != "line":
lx, ly = plot_x1 + 10, plot_y0 + 12
for i, g in enumerate(groups):
yleg = ly + i*18
elements.append(f'<rect x="{lx}" y="{yleg-9}" width="10" height="10" fill="{group_color[g]}"/>')
elements.append(svg_text(lx+14, yleg, str(g), size=10, anchor="start"))
elements.append(svg_text(plot_x0, height-4, f"Generated as a first-pass SVG; verify source, units, uncertainty, and accessibility before publication.", size=9, anchor="start"))
elements.append("</svg>")
metadata = {"chart": chart, "x": x, "y": y, "group": group, "rows_input": len(rows), "points_rendered": len(data), "warnings": warnings, "integrity_defaults": ["bar charts include zero baseline", "axes and units should be reviewed", "source and uncertainty must be added if relevant"]}
return "\n".join(elements), metadata
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Render a simple SVG chart from CSV using dependency-free, honest defaults.")
parser.add_argument("--csv", type=Path, required=True, help="Input CSV with a header row.")
parser.add_argument("--x", required=True, help="Column for x/category/time/relationship axis.")
parser.add_argument("--y", required=True, help="Numeric column for y axis.")
parser.add_argument("--group", help="Optional grouping column.")
parser.add_argument("--chart", choices=("auto", "bar", "dot", "line", "scatter"), default="auto")
parser.add_argument("--title", default="", help="Chart title.")
parser.add_argument("--width", type=int, default=900)
parser.add_argument("--height", type=int, default=520)
parser.add_argument("--output", type=Path, required=True, help="Output SVG path.")
parser.add_argument("--metadata", type=Path, help="Optional JSON metadata output path.")
args = parser.parse_args(argv)
if args.width < 400 or args.height < 300:
print("Error: width must be >=400 and height >=300.", file=sys.stderr)
return 2
rows = read_csv(args.csv)
headers = set(rows[0].keys())
for col in [args.x, args.y, args.group]:
if col and col not in headers:
print(f"Error: column not found: {col}", file=sys.stderr)
return 2
svg, meta = render(rows, args.x, args.y, args.chart, args.group, args.title, args.width, args.height)
args.output.write_text(svg, encoding="utf-8")
if args.metadata:
args.metadata.write_text(json.dumps(meta, indent=2), encoding="utf-8")
print(json.dumps({"output": str(args.output), "metadata": meta}, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Suggest display families from a CSV's structure and a stated goal."""
from __future__ import annotations
import argparse
import csv
import json
import re
import statistics
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
DATE_RE = re.compile(r"^(\d{4}-\d{1,2}-\d{1,2}|\d{1,2}/\d{1,2}/\d{2,4}|\d{4}-\d{1,2})$")
RATE_RE = re.compile(r"(rate|percent|percentage|pct|share|ratio|per_|per100|per_100|index|score|yield|density)", re.I)
COUNT_RE = re.compile(r"(count|n$|num|number|total|units|cases|incidents|defects|claims|clicks|population)", re.I)
GEO_RE = re.compile(r"(country|state|county|city|region|latitude|longitude|lat|lon|lng|postcode|zip)", re.I)
TIME_NAME_RE = re.compile(r"(date|time|year|month|week|day|quarter)", re.I)
def to_float(v: str) -> Optional[float]:
try:
return float(v.replace(",", ""))
except (AttributeError, ValueError):
return None
def infer_value(name: str, values: List[str]) -> str:
sample = [str(v).strip() for v in values if v is not None and str(v).strip() != ""]
if not sample:
return "empty"
if TIME_NAME_RE.search(name):
date_hits = sum(1 for v in sample if DATE_RE.match(v))
if date_hits / len(sample) >= 0.4:
return "date_or_time"
numeric = sum(1 for v in sample if to_float(v) is not None)
dates = sum(1 for v in sample if DATE_RE.match(v))
if dates / len(sample) >= 0.6:
return "date_or_time"
if numeric / len(sample) >= 0.85:
return "quantitative"
unique_ratio = len(set(sample)) / max(1, len(sample))
if unique_ratio < 0.5 or len(set(sample)) <= 30:
return "categorical"
return "text_or_id"
def numeric_stats(values: List[str]) -> Dict[str, Optional[float]]:
nums = [x for x in (to_float(str(v).strip()) for v in values) if x is not None]
if not nums:
return {"min": None, "max": None, "mean": None}
return {"min": min(nums), "max": max(nums), "mean": statistics.fmean(nums)}
def inspect_csv(path: Path, max_rows: int) -> Dict[str, Any]:
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise SystemExit("Error: CSV has no header row.")
rows = []
for idx, row in enumerate(reader):
if idx >= max_rows:
break
rows.append(row)
except FileNotFoundError:
raise SystemExit(f"Error: CSV file not found: {path}")
except csv.Error as exc:
raise SystemExit(f"Error: could not parse CSV: {exc}")
columns = []
for name in reader.fieldnames or []:
vals = [row.get(name, "") for row in rows]
kind = infer_value(name, vals)
missing = sum(1 for v in vals if v is None or str(v).strip() == "")
nonmissing = [str(v).strip() for v in vals if v is not None and str(v).strip() != ""]
unique = len(set(nonmissing))
role = ""
if kind == "quantitative":
if RATE_RE.search(name):
role = "rate_or_normalized_measure"
elif COUNT_RE.search(name):
role = "count_or_exposure"
else:
role = "measure"
elif kind == "categorical" and GEO_RE.search(name):
role = "geographic_category"
elif kind == "date_or_time":
role = "time"
columns.append({"name": name, "type": kind, "role": role, "missing_in_sample": missing, "unique_in_sample": unique, "stats": numeric_stats(vals) if kind == "quantitative" else None})
return {"path": str(path), "sample_rows": len(rows), "columns": columns}
def choose_primary_quant(cols: List[Dict[str, Any]]) -> Optional[str]:
q = [c for c in cols if c["type"] == "quantitative"]
if not q:
return None
for c in q:
if c.get("role") == "rate_or_normalized_measure":
return c["name"]
non_exposure = [c for c in q if c.get("role") != "count_or_exposure"]
if non_exposure:
return non_exposure[0]["name"]
return q[0]["name"]
def recommendations(profile: Dict[str, Any], goal: str, question: str = "") -> List[Dict[str, Any]]:
cols = profile["columns"]
q = [c["name"] for c in cols if c["type"] == "quantitative"]
cat = [c["name"] for c in cols if c["type"] == "categorical"]
geo = [c["name"] for c in cols if c.get("role") == "geographic_category"]
time = [c["name"] for c in cols if c["type"] == "date_or_time"]
primary = choose_primary_quant(cols)
recs: List[Dict[str, Any]] = []
def add(display: str, why: str, caution: str = "", checks: Optional[List[str]] = None, priority: str = "candidate") -> None:
recs.append({"display": display, "priority": priority, "why": why, "caution": caution, "integrity_checks": checks or []})
normalized_available = any(c.get("role") == "rate_or_normalized_measure" for c in cols)
count_available = any(c.get("role") == "count_or_exposure" for c in cols)
if goal == "auto":
if time and primary:
display = "time-series line chart"
if cat:
display += " with direct labels or small multiples"
add(display, f"{time[0]} supplies order and {primary} is the likely outcome measure.", "Use a common scale across panels if cross-group magnitude matters.", ["label time window", "show units", "annotate relevant events"], "strong")
if cat and primary:
add("sorted dot plot or zero-baseline bar chart", f"{cat[0]} can group {primary} for comparison.", "Use bars only when absolute magnitude from zero is the task; dots work well for compact ranked comparisons or intervals.", ["sort deliberately", "show units", "check zero baseline if bars"], "strong" if not time else "candidate")
if len(q) >= 2:
x, y = q[0], primary if primary and primary != q[0] else q[1]
add("scatterplot", f"{x} and {y} support relationship, outlier, and leverage checks.", "Use transparency or density if there are many rows; label transformations.", ["check overplotting", "show fit only if model is meaningful"], "candidate")
if primary:
add("distribution display", f"{primary} can be inspected for spread, skew, and outliers.", "Avoid summarizing by mean alone if distribution affects the decision.", ["show sample size", "consider raw points"], "candidate")
if geo and primary:
add("map plus companion ranking", f"{geo[0]} suggests geography may matter for {primary}.", "Normalize by exposure when raw counts mostly reflect population or opportunity.", ["check denominator", "use a companion table/dot plot"], "candidate")
if count_available and not normalized_available:
add("rate or normalized measure before plotting", "The CSV appears to include raw counts or exposure fields but no obvious rate/share column.", "Create an appropriate denominator before making population- or opportunity-sensitive comparisons.", ["identify denominator", "label rate definition"], "caution")
if not recs:
add("table", "The sampled columns do not clearly support a quantitative chart.", "Clarify the analytical question or provide typed columns.", ["verify headers", "identify units"], "fallback")
elif goal == "lookup":
add("table or text-table", "Exact values are the main task.", "Sort and round deliberately; add inline bars only if pattern comparison matters.", ["align numbers", "show units"], "strong")
elif goal == "trend":
add("time-series line chart", "Ordered time is needed for trend, seasonality, or event effects.", "If no time column exists, identify or create one before plotting.", ["label time window", "annotate events", "use common scales for groups"], "strong")
elif goal == "comparison":
add("sorted bar or dot plot", "Magnitude comparison across categories is central.", "Bars need zero baselines; dots work well for many categories or intervals.", ["sort categories", "show units", "check baseline"], "strong")
elif goal == "relationship":
add("scatterplot", "Relationships need at least two quantitative variables.", "Label transformations and use density methods for overplotting.", ["check outliers", "avoid unjustified causal language"], "strong")
elif goal == "distribution":
add("histogram, dot plot, box plot, or violin", "Distribution tasks need spread, tails, and outliers.", "Choose a form the audience can read; show raw points when sample size is modest.", ["show sample size", "choose binning deliberately"], "strong")
elif goal == "geography":
add("map plus companion ranking", "Spatial position matters for geographic questions.", "Normalize by exposure when raw counts mostly reflect population or opportunity.", ["check denominator", "state projection/boundaries if relevant"], "strong")
elif goal == "uncertainty":
add("interval, band, fan, or distribution display", "Estimates and forecasts need uncertainty visible.", "Label what the interval means.", ["show interval definition", "avoid false precision"], "strong")
return recs
def print_markdown(profile: Dict[str, Any], recs: List[Dict[str, Any]]) -> None:
print(f"Rows sampled: {profile['sample_rows']}")
print("Columns:")
for col in profile["columns"]:
extra = f", role={col['role']}" if col.get("role") else ""
print(f"- {col['name']}: {col['type']}{extra} ({col['unique_in_sample']} unique, {col['missing_in_sample']} missing in sample)")
print("\nRecommendations:")
for rec in recs:
line = f"- [{rec['priority']}] {rec['display']}: {rec['why']}"
if rec.get("caution"):
line += f" Caution: {rec['caution']}"
print(line)
if rec.get("integrity_checks"):
print(" Checks: " + "; ".join(rec["integrity_checks"]))
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Inspect a CSV and suggest quantitative display families.")
parser.add_argument("--csv", type=Path, required=True, help="Input CSV with a header row.")
parser.add_argument("--goal", choices=("auto", "lookup", "trend", "comparison", "relationship", "distribution", "geography", "uncertainty"), default="auto")
parser.add_argument("--question", default="", help="Optional user question to include in output metadata.")
parser.add_argument("--max-rows", type=int, default=500, help="Rows to sample for type inference. Default: 500.")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
args = parser.parse_args(argv)
if args.max_rows <= 0:
print("Error: --max-rows must be positive.", file=sys.stderr)
return 2
profile = inspect_csv(args.csv, args.max_rows)
result = {"profile": profile, "goal": args.goal, "question": args.question, "recommendations": recommendations(profile, args.goal, args.question)}
if args.format == "json":
print(json.dumps(result, indent=2))
else:
print_markdown(profile, result["recommendations"])
return 0
if __name__ == "__main__":
raise SystemExit(main())