Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 102 additions & 46 deletions plots/scatter-connected-temporal/implementations/python/altair.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
""" pyplots.ai
""" anyplot.ai
scatter-connected-temporal: Connected Scatter Plot with Temporal Path
Library: altair 6.0.0 | Python 3.14.3
Quality: 87/100 | Created: 2026-03-13
Library: altair 6.2.1 | Python 3.13.13
Quality: 89/100 | Updated: 2026-06-09
"""

import altair as alt
import importlib
import os
import sys


# This file is named altair.py — remove the script directory from sys.path so
# importlib.import_module('altair') resolves the installed package, not this file.
_path0 = sys.path.pop(0)
alt = importlib.import_module("altair")
sys.path.insert(0, _path0)
import numpy as np
import pandas as pd
from PIL import Image


# Data — US-style unemployment vs inflation over 30 years
# --- Theme tokens — Imprint palette ---
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# --- Data — US-style unemployment vs inflation (Phillips curve, 1994–2023) ---
np.random.seed(42)
years = np.arange(1994, 2024)
n = len(years)
Expand All @@ -25,7 +42,7 @@
unemployment[i] = np.clip(unemployment[i], 3.0, 10.5)
inflation[i] = np.clip(inflation[i], -0.5, 6.0)

# Add a recession spike around 2008-2010
# Recession spike around 20082010
unemployment[14:17] += np.array([2.5, 4.0, 3.5])
inflation[14:17] -= np.array([1.0, 1.5, 0.5])
unemployment = np.clip(unemployment, 3.0, 10.5)
Expand All @@ -35,45 +52,46 @@
{"year": years, "unemployment": np.round(unemployment, 1), "inflation": np.round(inflation, 1), "order": range(n)}
)

# Label key time points with nudged positions to avoid crowding
# Key year annotations with nudged positions to avoid crowding
label_years = [1994, 2000, 2008, 2010, 2015, 2023]
df_labels = df[df["year"].isin(label_years)].copy()
nudge = {2015: (-0.25, 0.35), 2023: (0.25, -0.35)}
nudge = {
1994: (0.28, 0.30),
2000: (0.28, 0.30),
2008: (0.25, -0.32),
2010: (-0.22, 0.35),
2015: (0.30, -0.38),
2023: (-0.28, -0.38),
}
df_labels["label_x"] = df_labels.apply(lambda r: r["unemployment"] + nudge.get(r["year"], (0, 0))[0], axis=1)
df_labels["label_y"] = df_labels.apply(lambda r: r["inflation"] + nudge.get(r["year"], (0, 0))[1], axis=1)

# Shared axis encodings
# --- Encodings ---
x_scale = alt.Scale(domain=[2.5, 8.5], nice=False)
y_scale = alt.Scale(domain=[-1.5, 5.8], nice=False)
axis_config = {
"labelFontWeight": "normal",
"titleColor": "#333333",
"labelColor": "#555555",
"tickColor": "#cccccc",
"gridDash": [3, 3],
"domain": False,
}
# Tightened lower bound — previous [-1.5, 5.8] wasted space below data
y_scale = alt.Scale(domain=[-0.8, 6.2], nice=False)

x_enc = alt.X("unemployment:Q", title="Unemployment Rate (%)", scale=x_scale, axis=alt.Axis(**axis_config))
y_enc = alt.Y("inflation:Q", title="Inflation Rate (%)", scale=y_scale, axis=alt.Axis(**axis_config))
x_enc = alt.X("unemployment:Q", title="Unemployment Rate (%)", scale=x_scale)
y_enc = alt.Y("inflation:Q", title="Inflation Rate (%)", scale=y_scale)

# Shared viridis color scale
viridis_scale = alt.Scale(scheme="viridis", domain=[1994, 2023])
viridis_legend = alt.Legend(
title="Year", titleFontSize=16, labelFontSize=15, format="d", gradientLength=300, gradientThickness=12
# Imprint sequential colormap for temporal progression: brand-green (1994) → blue (2023)
imprint_seq_scale = alt.Scale(range=["#009E73", "#4467A3"], domain=[1994, 2023])
year_legend = alt.Legend(
title="Year", titleFontSize=10, labelFontSize=10, format="d", gradientLength=160, gradientThickness=10
)

# Connecting path in temporal order — neutral gray to avoid color conflicts
path = alt.Chart(df).mark_line(strokeWidth=2.5, opacity=0.35, color="#666666").encode(x=x_enc, y=y_enc, order="order:Q")
# --- Chart layers ---
# Connecting path in temporal order — increased opacity (0.60) for legible trajectory
path = alt.Chart(df).mark_line(strokeWidth=2.5, opacity=0.60, color=INK_SOFT).encode(x=x_enc, y=y_enc, order="order:Q")

# Points colored by temporal progression — carries the viridis legend
# Points colored by temporal progression using Imprint sequential cmap
points = (
alt.Chart(df)
.mark_point(filled=True, size=180, stroke="white", strokeWidth=1.2)
.mark_point(filled=True, size=160, opacity=0.85, stroke="white", strokeWidth=1.2)
.encode(
x=x_enc,
y=y_enc,
color=alt.Color("year:Q", scale=viridis_scale, legend=viridis_legend),
color=alt.Color("year:Q", scale=imprint_seq_scale, legend=year_legend),
tooltip=[
alt.Tooltip("year:Q", title="Year", format="d"),
alt.Tooltip("unemployment:Q", title="Unemployment (%)", format=".1f"),
Expand All @@ -82,37 +100,75 @@
)
)

# Year annotations for key points with nudged positions
# Year annotations for key time points
annotations = (
alt.Chart(df_labels)
.mark_text(fontSize=16, fontWeight="bold", color="#333333", dy=-16)
.mark_text(fontSize=11, fontWeight="bold", color=INK, dy=-15)
.encode(x=alt.X("label_x:Q"), y=alt.Y("label_y:Q"), text=alt.Text("year:Q", format="d"))
)

# Compose layers
# --- Compose + configure ---
# Canvas: 620×320 inner view (landscape) → target PNG 3200×1800 after scale_factor=4
chart = (
(path + points + annotations)
.properties(
width=1600,
height=900,
width=620,
height=320,
background=PAGE_BG,
title=alt.Title(
"scatter-connected-temporal · altair · pyplots.ai",
fontSize=28,
color="#222222",
"scatter-connected-temporal · python · altair · anyplot.ai",
fontSize=16,
color=INK,
subtitle="Unemployment vs. Inflation — tracing the Phillips curve path (1994–2023)",
subtitleFontSize=16,
subtitleColor="#777777",
subtitlePadding=6,
subtitleFontSize=10,
subtitleColor=INK_SOFT,
subtitlePadding=4,
),
)
.configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)
.configure_axis(
labelFontSize=18, titleFontSize=22, titlePadding=12, grid=True, gridOpacity=0.15, gridColor="#cccccc"
labelFontSize=10,
labelColor=INK_SOFT,
titleFontSize=12,
titleColor=INK,
titlePadding=8,
domainColor=INK_SOFT,
tickColor=INK_SOFT,
grid=True,
gridOpacity=0.15,
gridColor=INK,
gridDash=[3, 3],
)
.configure_title(color=INK)
.configure_legend(
orient="right",
padding=10,
fillColor=ELEVATED_BG,
strokeColor=INK_SOFT,
labelColor=INK_SOFT,
titleColor=INK,
labelFontSize=10,
titleFontSize=10,
)
.configure_view(strokeWidth=0)
.configure_legend(orient="right", padding=10)
.interactive()
)

# Save
chart.save("plot.png", scale_factor=3.0)
chart.save("plot.html")
# --- Save ---
TW, TH = 3200, 1800
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

# PAD-only to exact 3200×1800 — do NOT crop (cropping clips labels, triggers AR-09)
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
raise SystemExit(
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
f"Shrink chart .properties(width=, height=) values and re-render."
)
if _w < TW or _h < TH:
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
_canvas.save(f"plot-{THEME}.png")

# Interactive HTML — untouched by padding
chart.save(f"plot-{THEME}.html")
Loading
Loading