A data visualization framework for The Planetary Society that creates consistent, branded charts for web and presentations.
- YAML-Driven Chart Generation - Define charts declaratively without writing Python code
- Interactive Chart Editor - Browser-based GUI for creating and editing charts with live preview
- Multiple Chart Types - Area, line, scatter, bar, donut, treemap, lollipop, stacked bar, waffle, grouped bar, US map pie, and line subplots
- Automatic Responsive Output - Generates desktop (16:10), mobile (8:9), and social card (2400x1260, 40:21 OG ratio) versions
- Multi-Format Export - SVG, PNG, PPTX, and CSV data export
- Flexible Data Sources - Google Sheets, CSV files, or custom controller methods
- TPS Brand Styling - Consistent Planetary Society branding with Poppins fonts and a house design language (see below)
Every chart shares one anatomy so TPS output is recognizable at a glance and honest by construction. The defaults implement all of this — a YAML file gets the house style for free, and the conventions below explain when to reach for which option.
- Canvas: Slushy Brine (
#F5F5F5) throughout, anchored by a single 2.5pt bottom spine (the "launchpad") — no top, right, or left spines. - Grid: horizontal-only hairlines (
#DBDBDB, solid, 0.8pt). The grid marks y positions, so y-axis tick stubs are suppressed; the x-axis keeps short 4pt ticks. No minor ticks anywhere. - Header: optional
eyebrow:kicker (Neptune Blue, uppercase, desktop only), a near-black title, and a gray subtitle. The plot stretches to fill whatever the header and footer don't use. - Footer: TPS logo plus a standardized
note:+source:line. Put methodology innote:— inflation basis, denominators, axis-zero disclosures — not in the subtitle. - Legends: frameless, and only when direct labels can't work. Prefer labeling series at their endpoints.
Hue carries meaning, not decoration. Assign colors by role, never by "what's next in the cycle looks nice":
| Role | Color | When |
|---|---|---|
| Enacted / actual / primary | Neptune Blue #037CC2 |
The series the chart is about |
| Taken away | Rocket Flame #FF5D47 |
Cuts, cancellations, at-risk programs. Reserved — in composition charts (donut/treemap/waffle) categories stay in blue/purple/gray families so red keeps its meaning |
| Secondary series | Plasma Purple #643788 |
A second entity compared against the primary |
| Comparison / tertiary | Medium Neptune #3FA9E0 |
Fourth slot in the cycle |
| Context | Lunar Soil #8C8C8C, Crater Shadow #414141 |
Historical background, de-emphasized series |
The automatic series cycle runs in exactly that order and is validated for colorblind-safe adjacency on the brine canvas. More than six series should fold into a gray "Other" or become small multiples — never invent a seventh hue.
Color carries valence; linestyle carries certainty:
- Solid = enacted, appropriated, actual.
- Dotted (
:) = proposed or projected — a budget request, a projection, anything not yet law. Dotted lines render as true round dots (the base style tuneslines.dotted_patternfor this). - Gray dashed (
--) = prior-year context viaseries_types: [prior, ...].
A solid red future asserts a proposal as fact; TPS's core subject is proposals, so the break between solid and dotted should land at the last enacted year.
- Lines are 3pt with no per-point markers. The featured series ends in the TPS
signature orbit-ring endpoint (a filled dot wearing an unfilled ring) with a boxed
direct label — on by default with direct labels, or explicit via
end_point: {marker: ring}. - Bars are flat-topped (no rounded ends — they ambiguate the measured value) with value
labels on the data (
show_values: true). - Data outside a configured
xlimwindow is clipped from the dataset on line, scatter, and subplot charts, so edge markers render whole and the y-axis scales to what is visible.
The chart states the takeaway; the reader shouldn't have to derive it:
chart:
eyebrow: "FY 2026 APPROPRIATIONS" # kicker above the title
note: "Budget authority in current dollars; marks are not enacted law."
annotations: # callouts in the direct-label box style
- x: -0.28
y: 18809100000
text: "$6B (24%) below\nCongress's marks"
arrow: trueEvery highlighted phrase, delta, or annotation must be literal and defensible from the plotted data — the credibility contract behind all advocacy output.
| Variant | Ratio | Job |
|---|---|---|
| Desktop | 16:10 | Article embeds; full header with eyebrow |
| Mobile | 8:9 | The direct social-share format — designed first; eyebrow dropped, titles wrap |
| Social | 40:21 (2400×1260) | Open Graph link preview only — intentionally headline-free, platforms show the page title |
# my_chart.yaml
data:
source: https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/export?format=csv
chart:
type: line
output: my_first_chart
title: "My Chart Title"
subtitle: "A descriptive subtitle for context"
source: "Data Source Attribution"
x: "{{Year}}"
y: ["{{Value1}}", "{{Value2}}"]
color: [NeptuneBlue, RocketFlame]
labels: ["Series A", "Series B"]CLI:
tpsplots generate my_chart.yamlInteractive Editor:
tpsplots editor # Opens a browser-based editor with live previewPython:
import tpsplots
result = tpsplots.generate("my_chart.yaml")
print(f"Generated {result['succeeded']} charts")Charts are saved to charts/ by default:
my_first_chart_desktop.svg/my_first_chart_mobile.svgmy_first_chart_desktop.png/my_first_chart_mobile.pngmy_first_chart_social.png(2400x1260 social card, 40:21 OG ratio, PNG only)my_first_chart.pptx(desktop only)my_first_chart.csv(ifexport_dataspecified)
TPS Plots uses a clean two-level YAML structure:
data: # Where data comes from
source: data/file.csv | https://... | controller.method
chart: # How to display it
type: line | bar | donut | ...
output: chart_name
title: "Chart Title"
... # Chart-specific parametersUse {{column_name}} syntax to reference data columns:
chart:
x: "{{Year}}" # Simple column reference
y: "{{data.series.values}}" # Nested dot notation
subtitle: "Total: {{sum:.2f}}" # Format string (2 decimal places)Track NASA budget over time with inflation adjustment:
# nasa_budget_pbr_appropriation_by_year_inflation_adjusted.yaml
data:
source: nasa_budget_chart.nasa_budget_by_year
chart:
type: line
output: nasa_budget_pbr_appropriation_by_year_inflation_adjusted
title: "NASA's budget from 1959 to {{metadata.max_appropriation_fiscal_year}}"
subtitle: Adjusted for inflation, NASA's budget has remained relatively steady after the end of Apollo.
source: "NASA Budget Requests, 1961-{{metadata.max_pbr_fiscal_year}}. Adjusted to {{metadata.inflation_adjusted_year}} USD."
x: "{{fiscal_year}}"
y:
- "{{pbr_adjusted}}"
- "{{appropriation_adjusted}}"
color: ["light_blue", "Neptune Blue"]
linestyle: [":", "-"]
linewidth: [2.5, 4]
labels: ["Proposed", "Final"]
xlim: [1958-01-01, 2030-01-01]
ylim: [0, 80000000000]
scale: billions
legend: false
export_data: "{{export_df}}"Compare spacecraft development timelines with data from a Google Sheet:
# human_spacecraft_dev_times.yaml
data:
source: https://docs.google.com/spreadsheets/d/1uYSirJmE0gLQ701iBFZASsTQL0od36l9xJ7jbEagnrY/export?format=csv
chart:
type: bar
output: human_spaceflight_development_comparisons
title: "A five year sprint to the Moon?"
subtitle: "A new entrant to provide crewed landings for NASA's Artemis program
by 2030 has five years to design and build their spacecraft."
source: "Official Reporting. Details: planet.ly/hsfdevtimes"
categories: "{{Spacecraft}}"
values: "{{Duration (years)}}"
orientation: horizontal
xlabel: "Years from prime contract award to first crewed flight"
sort_by: value
sort_ascending: false
height: 0.5
grid: true
xlim: [0, 20]
show_values: true
value_format: ".1f"
value_suffix: " yrs"
value_fontsize: 10
label_size: 14
tick_size: 16
colors:
- LunarSoil # Mercury
- LunarSoil # Gemini
- LunarSoil # Apollo CSM
- LunarSoil # Apollo LM
- LunarSoil # Shuttle Orbiter
- LunarSoil # Orion
- LunarSoil # Crew Dragon
- LunarSoil # Starliner
- LunarSoil # Starship HLS
- LunarSoil # Blue Moon
- RocketFlame # New HLS Entrant (highlighted)Visualize development periods with start and end dates:
# human_spaceflight_development_times.yaml
data:
source: https://docs.google.com/spreadsheets/d/1uYSirJmE0gLQ701iBFZASsTQL0od36l9xJ7jbEagnrY/export?format=csv
chart:
type: lollipop
output: human_spaceflight_development_comparisons
title: "Human Spaceflight Development Times Comparison"
subtitle: "To make a 2030 landing deadline, a new human landing spacecraft would
need be the fastest program development in 65 years."
source: "NASA Historical Data"
categories: "{{Spacecraft}}"
start_values: "{{Start Date_year}}"
end_values: "{{First Crewed Utilization_year}}"
xlim: [1959, 2031]
y_axis_position: right
hide_y_spine: true
# Color by era: blue for historical, purple for future
colors:
- NeptuneBlue # Mercury through Starliner (historical)
- NeptuneBlue
- NeptuneBlue
- NeptuneBlue
- NeptuneBlue
- NeptuneBlue
- NeptuneBlue
- NeptuneBlue
- PlasmaPurple # Future HLS projects
- PlasmaPurple
- PlasmaPurple
# Solid for completed, dotted for projected
linestyle: ["-", "-", "-", "-", "-", "-", "-", "-", ":", ":", ":"]
marker_size: 5
line_width: 5
grid: true
grid_axis: x
range_labels: true
range_suffix: " yrs"
export_data: "{{data}}"Show NASA budget allocation by directorate:
# nasa_major_activities_donut.yaml
data:
source: nasa_budget_chart.nasa_major_activites_donut_chart
chart:
type: donut
output: nasa_directorate_breakdown_donut_chart
title: NASA's budget is subdivided by mission area
subtitle: "Directorates are responsible for distinct activities — from science
to facilities — and they don't share funding."
source: NASA FY2025 Budget Request
values: "{{sorted_values}}"
labels: "{{sorted_labels}}"
show_percentages: true
label_distance: 1.1
hole_size: 0.6
center_text: NASA
center_color: white
export_data: "{{export_df}}"Compare contract awards across fiscal years:
# fy2025_new_contracts_comparison.yaml
data:
source: fy2025_charts.new_contract_awards_comparison_to_prior_years
chart:
type: line
output: new_contracts_historical_comparison
title: "NASA's total new contract awards in FY 2025"
subtitle: "While below the recent average, the total number was similar to that in 2024."
source: "USASpending.gov (Does not include IDVs)"
x: "{{months}}"
y: "{{y_series}}"
color: "{{colors}}"
linestyle: "{{linestyles}}"
linewidth: "{{linewidths}}"
marker: "{{markers}}"
labels: "{{labels}}"
ylim: [0, 6000]
ylabel: "Cumulative New Contracts Awarded"
label_size: 13
tick_size: 14
grid: true
legend: false
direct_line_labels:
fontsize: 10
export_data: "{{export_df}}"| Type | Description | Key Parameters |
|---|---|---|
area |
Ordinary or stacked filled series | x, y, stacked, color, labels |
line |
Multi-series line charts | x, y, color, linestyle, marker, labels |
scatter |
Scatter plots (line chart variant) | x, y, color, marker, labels |
bar |
Vertical or horizontal bars | categories, values, orientation, colors |
donut |
Donut/pie charts | values, labels, hole_size, center_text |
treemap |
Space-filling composition charts | labels, values, colors, show_percentages |
lollipop |
Timeline/range visualization | categories, start_values, end_values, colors |
stacked_bar |
Stacked bar charts | categories, values, colors |
waffle |
Waffle/grid charts | values, labels, rows, columns |
grouped_bar |
Side-by-side grouped bars | categories, groups, width, colors |
us_map_pie |
US map with pie overlays | state_data, pie_values |
line_subplots |
Multiple subplot panels (CLI only) | subplot_data, grid_shape |
List all available types: tpsplots --list-types
Turn a static chart into a short MP4 of it building itself out — lines and areas revealing left-to-right with smooth easing, bars growing from the baseline in a staggered cascade, labels settling into place. These are made for Instagram and YouTube, where a chart that animates on autoplay stops the scroll.
The continuous-series and bar families animate today: area, line, scatter, bar, grouped_bar, stacked_bar, and lollipop. Other chart types report a clear "not animatable" message and the batch continues. Each run writes {output}_{format}.mp4 plus a poster PNG of the final frame. The default output is a square 1080×1080 video; use --format for landscape (1920×1080), portrait (1080×1920), or all for every aspect at once.
The video is the chart panel only — no title, subtitle, source line, or logo. Titles and branding are composited in your video editor, which is what lets the same chart work cropped to square, landscape, and portrait.
# Square 1080×1080 (default)
tpsplots animate yaml/chart.yaml
# Every aspect ratio at once
tpsplots animate --format all yaml/chart.yaml
# 4K landscape for YouTube (super-samples the encode)
tpsplots animate --scale 2 --format landscape yaml/chart.yamlTune the motion per chart with an optional top-level animation: block. Any value here is overridden by the matching CLI flag when it is passed, and falls back to a built-in default when it is not:
animation:
formats: [square, landscape] # square | landscape | portrait | all
fps: 60 # draft quality caps this at 30
duration: 2.0 # draw-phase length in seconds
easing: cubic_in_out # e.g. cubic_in_out (default), quint_out, glide_pop
end_hold: 3.0 # seconds to hold on the final frameEncoding needs an ffmpeg binary. Install the optional extra to bundle a private one:
uv sync --extra animate # or: pip install 'tpsplots[animate]'The bundled imageio-ffmpeg binary is GPL-licensed; tpsplots only invokes it as a subprocess, so it carries no licensing impact for tpsplots itself. A system ffmpeg (brew install ffmpeg) works too.
Use a single data.source string. Optional prefixes (csv:, url:, controller:) can make intent explicit.
Fetch data directly from any URL that returns CSV:
data:
source: https://docs.google.com/spreadsheets/d/SHEET_ID/export?format=csvTips:
- Sheet must be publicly accessible or "Anyone with link can view"
- Use the
/export?format=csvURL format - For specific sheets/tabs, add
&gid=SHEET_GID - Currency columns (e.g.,
$42,013) are auto-cleaned to numeric values
Load from a local CSV file:
data:
source: data/my_data.csvCustomize data loading with optional params:
data:
source: https://docs.google.com/spreadsheets/d/SHEET_ID/export?format=csv
params:
columns: # Keep only these columns
- "Fiscal Year"
- "Amount"
cast: # Convert column types
Fiscal Year: int
Amount: float
renames: # Rename columns
"Old Name": "New Name"
auto_clean_currency: true # Auto-detect and clean $X,XXX columns (default for URLs)Apply inflation adjustment to columns directly from YAML:
data:
source: https://docs.google.com/spreadsheets/d/SHEET_ID/export?format=csv
calculate_inflation:
columns: # Columns to adjust
- "Apollo"
- "Artemis"
type: nnsi # "nnsi" (default) or "gdp"
fiscal_year_column: "Fiscal Year" # Column with fiscal years
target_year: 2025 # Target FY (auto-calculated if omitted)Creates new columns named {column}_adjusted_{type} (e.g., Apollo_adjusted_nnsi).
Use existing Python controllers for advanced data manipulation:
data:
source: nasa_budget_chart.nasa_budget_by_year_inflation_adjustedController modules must contain exactly one ChartController subclass with the method, and the method must return a dict.
If the controller lives outside your Python path, provide a local file path:
data:
source: /path/to/custom_controller.py:my_data_methodAvailable Controllers (module names):
nasa_budget_chart- NASA budget analysisfy2025_charts/fy2026_chartschina_comparisons_controllermission_spending_controller
Use TPS brand color names instead of hex codes. The automatic series cycle runs in this order (see Design Language for when each role applies):
| Name | Hex | Role |
|---|---|---|
NeptuneBlue |
#037CC2 |
Primary — enacted / actual |
RocketFlame |
#FF5D47 |
Taken away — cuts, cancellations (reserved in composition charts) |
PlasmaPurple |
#643788 |
Secondary series |
MediumNeptune |
#3FA9E0 |
Comparison / tertiary |
LunarSoil |
#8C8C8C |
Context gray |
CraterShadow |
#414141 |
Dark context / text ink |
Full palette in tpsplots/colors.py.
The CLI uses subcommands for different operations:
tpsplots [OPTIONS] COMMAND [ARGS]...--version Show version and exit
--schema Print JSON Schema for YAML configuration and exit
--list-types List available chart types and exit
--new TYPE Generate a YAML template for the specified chart type
--help Show help message
tpsplots generate [OPTIONS] INPUTS...| Option | Description |
|---|---|
-o, --outdir PATH |
Output directory (default: charts/) |
-q, --quiet |
Suppress progress output |
--verbose |
Enable verbose/debug logging |
tpsplots validate [OPTIONS] INPUTS...| Option | Description |
|---|---|
-q, --quiet |
Suppress progress output |
--verbose |
Enable verbose/debug logging |
tpsplots editor [OPTIONS] [YAML_DIR]| Option | Description |
|---|---|
YAML_DIR |
Directory containing YAML chart configs (default: yaml/) |
--host TEXT |
Host interface (default: 127.0.0.1) |
--port INT |
Port for the editor server (default: auto-select) |
--open-browser/--no-open-browser |
Auto-open in browser (default: on) |
tpsplots s3-sync [OPTIONS]| Option | Description |
|---|---|
-d, --local-dir PATH |
Local directory to upload (required) |
-b, --bucket TEXT |
S3 bucket name (required) |
-p, --prefix TEXT |
S3 prefix/path within bucket (required) |
-n, --dry-run |
Preview changes without uploading |
# Generate single chart
tpsplots generate yaml/my_chart.yaml
# Process entire directory
tpsplots generate yaml/
# Validate without generating
tpsplots validate yaml/my_chart.yaml
# Custom output directory
tpsplots generate -o output/ yaml/
# Generate JSON Schema for IDE support
tpsplots --schema > tpsplots-schema.json
# Generate a new chart template
tpsplots --new line > yaml/my_new_chart.yaml
# Launch the interactive chart editor
tpsplots editor yaml/
# Upload charts to S3 (dry-run first)
tpsplots s3-sync -d charts -b mybucket -p assets/charts/ --dry-runimport tpsplots
# Generate from single file
result = tpsplots.generate("chart.yaml")
# Generate from directory
result = tpsplots.generate("yaml/", outdir="output/")
# With options
result = tpsplots.generate(
"chart.yaml",
outdir="custom_output/",
quiet=True # Suppress logging
)
# Check results
print(f"Succeeded: {result['succeeded']}")
print(f"Failed: {result['failed']}")
print(f"Files: {result['files']}")
print(f"Errors: {result['errors']}")import tpsplots
from tpsplots import ConfigurationError, DataSourceError, RenderingError
try:
result = tpsplots.generate("chart.yaml")
except ConfigurationError as e:
print(f"Invalid YAML configuration: {e}")
except DataSourceError as e:
print(f"Could not load data: {e}")
except RenderingError as e:
print(f"Chart rendering failed: {e}")tpsplots/
├── __init__.py # Public API and package initialization
├── api.py # generate() function implementation
├── cli.py # Command-line interface
├── exceptions.py # TPSPlotsError, ConfigurationError, etc.
├── schema.py # JSON Schema generation
├── assets/ # Bundled resources
│ ├── fonts/Poppins/ # TPS brand fonts
│ └── images/ # TPS logo
├── commands/ # CLI subcommands (editor, etc.)
├── editor/ # Interactive chart editor
│ ├── app.py # Starlette ASGI app
│ ├── session.py # EditorSession: YAML ↔ form state
│ ├── ui_schema.py # JSON Schema / uiSchema generation
│ ├── routes/ # API endpoints (schema, preview, files, data)
│ ├── static/ # Frontend (React 19, htm, CSS)
│ └── templates/ # HTML shell
├── controllers/ # Chart generation logic
│ ├── nasa_budget_chart.py
│ ├── fy2025_charts.py
│ └── ...
├── data_sources/ # Data loading and processing
│ ├── google_sheets_source.py
│ ├── nasa_budget_data_source.py
│ └── ...
├── models/ # Pydantic validation models
│ ├── chart_config.py # ChartConfig discriminated union
│ ├── charts/ # Per-chart-type config models
│ │ ├── line.py # LineChartConfig, SeriesConfig, etc.
│ │ ├── bar.py # BarChartConfig
│ │ ├── donut.py # DonutChartConfig
│ │ └── ... # One file per chart type
│ ├── mixins/ # Shared field groups
│ │ ├── base.py # ChartConfigBase (common fields)
│ │ ├── shared.py # SharedFieldsMixin (axes, grid, ticks)
│ │ └── bar.py # BarFieldsMixin (bar-specific styling)
│ ├── data_sources.py
│ ├── parameters.py
│ └── yaml_config.py
├── processors/ # YAML processing pipeline
│ ├── yaml_chart_processor.py
│ ├── render_pipeline.py # Shared render context (CLI + editor)
│ └── resolvers/ # Data, parameter, metadata resolution
├── templates/ # Chart type templates for --new
├── utils/ # Shared utilities
│ ├── currency_processing.py
│ ├── date_processing.py
│ └── formatting.py
└── views/ # Chart visualization classes
├── chart_view.py # Base class
├── line_chart.py
├── bar_chart.py
├── lollipop_chart.py
├── donut_chart.py
└── style/ # Matplotlib style files
For complex data processing, create a custom controller:
# tpsplots/controllers/my_controller.py
from tpsplots.controllers.chart_controller import ChartController
class MyCustomChart(ChartController):
def my_data_method(self):
"""Return data for YAML charts."""
# Load and process data
df = self.load_my_data()
# Return dict with data arrays
return {
"years": df["Year"].values,
"values": df["Value"].values,
"labels": ["Label 1", "Label 2"],
"export_df": df, # For CSV export
}Then reference in YAML:
data:
source: my_controller.my_data_method
# Optional: load a local controller file outside the package
# source: /path/to/custom_controller.py:my_data_methodGenerate a JSON Schema for IDE support:
tpsplots --schema > .vscode/tpsplots-schema.jsonIn VS Code, add to .vscode/settings.json:
{
"yaml.schemas": {
".vscode/tpsplots-schema.json": "yaml/*.yaml"
}
}| Variable | Description |
|---|---|
TPSPLOTS_HEADLESS |
Force headless mode (1/true) or GUI mode (0/false) |
# Install with dev dependencies
uv sync --extra dev
# Run tests
pytest
# Run linting
ruff check tpsplots/
# Format code
ruff format tpsplots/Each chart view has a CONFIG_CLASS linking it to its Pydantic config model. The test at tests/test_config_view_sync.py uses AST analysis to verify every kwargs.pop("key") in view code has a matching config model field. This prevents configuration drift between models and rendering code. Run it with:
pytest tests/test_config_view_sync.py -vThis project is released under the Business Source License 1.1. See LICENSE.
TPS branding (name, logos, and brand assets) is All Rights Reserved; see NOTICE.
Poppins fonts are licensed under the SIL Open Font License 1.1.
Casey Dreier - The Planetary Society