#!/usr/bin/env python3
"""
build_infographic.py — Generate self-contained HTML/SVG infographics from JSON data.
Supported infographic types:
- stats: KPI / statistics card grid
- comparison: Grouped bar chart with multiple series
- flow: Process / step-by-step flow diagram
- dashboard: Mixed layout (stats + chart + breakdown donut)
Usage:
python3 build_infographic.py config.json
cat config.json | python3 build_infographic.py
Output: self-contained HTML file (all CSS/SVG inline, no external deps).
"""
import json
import math
import os
import sys
from html import escape as _esc
# ---------------------------------------------------------------------------
# Color palettes
# ---------------------------------------------------------------------------
PALETTES = {
"ocean": {
"bg": "#eef4fb", "card": "#ffffff", "primary": "#0077b6",
"text": "#023e8a", "text_light": "#4a6fa5",
"colors": ["#0077b6", "#00b4d8", "#48cae4", "#90e0ef", "#0096c7", "#005f8a"],
"up": "#0a9396", "down": "#e63946",
},
"sunset": {
"bg": "#fef6f0", "card": "#ffffff", "primary": "#e76f51",
"text": "#1d3557", "text_light": "#6b7b8d",
"colors": ["#e63946", "#f4845f", "#f7b267", "#e76f51", "#c1121f", "#d4a373"],
"up": "#2a9d8f", "down": "#e63946",
},
"forest": {
"bg": "#eef7ee", "card": "#ffffff", "primary": "#2d6a4f",
"text": "#1b4332", "text_light": "#6b8f71",
"colors": ["#2d6a4f", "#40916c", "#52b788", "#74c69d", "#95d5b2", "#1b4332"],
"up": "#2d6a4f", "down": "#c1121f",
},
"berry": {
"bg": "#f8f0fa", "card": "#ffffff", "primary": "#7b2cbf",
"text": "#3c096c", "text_light": "#8a6baa",
"colors": ["#7b2cbf", "#9d4edd", "#c77dff", "#e0aaff", "#5a189a", "#240046"],
"up": "#2a9d8f", "down": "#e63946",
},
"vibrant": {
"bg": "#f5f6fa", "card": "#ffffff", "primary": "#4361ee",
"text": "#1a1a2e", "text_light": "#6c757d",
"colors": ["#4361ee", "#f72585", "#4cc9f0", "#7209b7", "#3a0ca3", "#f77f00"],
"up": "#06d6a0", "down": "#ef476f",
},
"corporate": {
"bg": "#f0f2f5", "card": "#ffffff", "primary": "#1a365d",
"text": "#1a202c", "text_light": "#718096",
"colors": ["#1a365d", "#2b6cb0", "#3182ce", "#63b3ed", "#2c5282", "#4a5568"],
"up": "#38a169", "down": "#e53e3e",
},
"pastel": {
"bg": "#fafafa", "card": "#ffffff", "primary": "#6c8ebf",
"text": "#333333", "text_light": "#888888",
"colors": ["#6c8ebf", "#d4a5a5", "#a8d8b9", "#f0d9b5", "#b8b8d1", "#f5c6aa"],
"up": "#68b684", "down": "#d4a5a5",
},
"earth": {
"bg": "#f9f5f0", "card": "#ffffff", "primary": "#8b5e3c",
"text": "#3e2723", "text_light": "#795548",
"colors": ["#8b5e3c", "#a0522d", "#c49a6c", "#ddc9a3", "#6d4c41", "#5d4037"],
"up": "#558b2f", "down": "#c62828",
},
}
_PALETTE_NAMES = list(PALETTES.keys())
# ---------------------------------------------------------------------------
# SVG icon paths (viewBox 0 0 24 24)
# ---------------------------------------------------------------------------
ICONS = {
"arrow-up": '',
"arrow-down": '',
"trending-up": '',
"trending-down": '',
"users": '',
"user": '',
"money": '',
"percent": '',
"globe": '',
"clock": '',
"check": '',
"star": '',
"target": '',
"zap": '',
"chart-bar": '',
"chart-pie": '',
"database": '',
"rocket": '',
"shield": '',
"heart": '',
"light": '',
"search": '',
"mail": '',
"settings": '',
"flag": '',
}
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
def _pick_palette(name, data):
if name and name != "auto" and name in PALETTES:
return PALETTES[name]
idx = hash(json.dumps(data, sort_keys=True, ensure_ascii=False)) % len(_PALETTE_NAMES)
return PALETTES[_PALETTE_NAMES[idx]]
def _get_color(palette, index):
colors = palette["colors"]
return colors[index % len(colors)]
def _svg_icon(name, size=24, color="currentColor"):
path_data = ICONS.get(name, ICONS.get("star", ""))
needs_fill = "fill=\"none\"" not in path_data and "stroke=" not in path_data
fill_attr = f' fill="{_esc(color)}"' if needs_fill else ""
return (
f''
)
def _format_number(v):
if isinstance(v, str):
return v
if isinstance(v, float):
if v >= 1_000_000:
return f"{v / 1_000_000:.1f}M"
if v >= 1_000:
return f"{v / 1_000:.1f}K"
return f"{v:.1f}"
if isinstance(v, int):
if v >= 1_000_000:
return f"{v / 1_000_000:.1f}M"
if v >= 1_000:
return f"{v:,}"
return str(v)
return str(v)
# ---------------------------------------------------------------------------
# SVG chart generators
# ---------------------------------------------------------------------------
def _svg_bar_chart(data, palette, chart_w=760, chart_h=360):
categories = data.get("categories", [])
series_list = data.get("series", [])
if not categories or not series_list:
return '
No chart data provided.
'
pad_l, pad_r, pad_t, pad_b = 65, 20, 30, 55
plot_w = chart_w - pad_l - pad_r
plot_h = chart_h - pad_t - pad_b
all_vals = [v for s in series_list for v in s.get("values", [])]
if not all_vals:
return 'No values in series.
'
max_val = max(all_vals) * 1.15 or 1
n_cat = len(categories)
n_ser = len(series_list)
group_w = plot_w / n_cat
bar_gap = max(2, group_w * 0.1)
bar_w = max(8, (group_w - bar_gap * 2) / n_ser)
lines = [
f'")
return "\n".join(lines)
def _svg_donut_chart(items, palette, size=280):
if not items:
return ""
total = sum(it.get("value", 0) for it in items) or 1
cx, cy = size / 2, size / 2
outer_r = size / 2 - 10
inner_r = outer_r * 0.6
lines = [
f'")
return "\n".join(lines)
def _svg_flow_diagram(steps, palette, step_w=180, step_h=90, gap=50):
n = len(steps)
if n == 0:
return ""
total_w = n * step_w + (n - 1) * gap + 40
total_h = step_h + 80
lines = [
f'")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Section builders
# ---------------------------------------------------------------------------
def _build_stats(data, palette):
items = data if isinstance(data, list) else data.get("items", data.get("stats", []))
if not items:
return 'No stats data provided.
'
cards = []
for i, item in enumerate(items):
label = _esc(str(item.get("label", "")))
value = _esc(str(item.get("value", "")))
icon_name = item.get("icon", "star")
trend = item.get("trend", "")
trend_dir = item.get("trend_dir", "up")
color = _get_color(palette, i)
trend_color = palette["up"] if trend_dir == "up" else palette["down"]
trend_icon = "trending-up" if trend_dir == "up" else "trending-down"
trend_html = ""
if trend:
trend_html = (
f''
f'{_svg_icon(trend_icon, 16, trend_color)} {_esc(str(trend))}'
f"
"
)
cards.append(
f''
f'
'
f'
'
f"{label}"
f'
'
f"{_svg_icon(icon_name, 22, color)}
"
f'
{value}
'
f"{trend_html}
"
)
return (
'{" ".join(cards)}
'
)
def _build_comparison(data, palette):
chart_title = data.get("chart_title", "")
title_html = ""
if chart_title:
title_html = (
f'{_esc(chart_title)}
'
)
chart_svg = _svg_bar_chart(data, palette)
return (
f'{title_html}{chart_svg}
'
)
def _build_flow(data, palette):
steps = data if isinstance(data, list) else data.get("steps", [])
if not steps:
return 'No flow data provided.
'
return (
f''
f"{_svg_flow_diagram(steps, palette)}
"
)
def _build_dashboard(data, palette):
sections = []
stats_data = data.get("stats", [])
if stats_data:
sections.append(_build_stats(stats_data, palette))
chart_data = data.get("chart")
if chart_data:
sections.append(_build_comparison(chart_data, palette))
breakdown = data.get("breakdown", [])
if breakdown:
donut_svg = _svg_donut_chart(breakdown, palette)
legend_items = []
for i, it in enumerate(breakdown):
color = it.get("color") or _get_color(palette, i)
label = _esc(str(it.get("label", "")))
val = it.get("value", 0)
legend_items.append(
f''
f''
f'{label}'
f''
f"{_format_number(val)}
"
)
sections.append(
f''
f'
{donut_svg}
'
f'
'
f'{"".join(legend_items)}
'
)
flow_data = data.get("flow", [])
if flow_data:
sections.append(_build_flow(flow_data, palette))
return "\n".join(sections)
# ---------------------------------------------------------------------------
# HTML wrapper
# ---------------------------------------------------------------------------
def _wrap_html(title, subtitle, body_content, footer, palette):
return f"""
{_esc(title)}
{_esc(title)}
{"" + _esc(subtitle) + "
" if subtitle else ""}
{body_content}
{"" if footer else ""}
"""
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _validate_config(config):
errors = []
if not isinstance(config, dict):
errors.append("Config must be a JSON object")
return errors
ig_type = config.get("type", "stats")
valid_types = {"stats", "comparison", "flow", "dashboard"}
if ig_type not in valid_types:
errors.append(f"Invalid type '{ig_type}'. Must be one of: {', '.join(sorted(valid_types))}")
if "data" not in config:
errors.append("Missing required field: data")
output = config.get("output", "")
if output:
out_dir = os.path.dirname(os.path.abspath(output))
if not os.path.isdir(out_dir):
errors.append(f"Output directory does not exist: {out_dir}")
return errors
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help"):
input_path = sys.argv[1]
try:
with open(input_path, "r", encoding="utf-8") as f:
config = json.load(f)
except FileNotFoundError:
print(json.dumps({"status": "error", "message": f"File not found: {input_path}"}))
sys.exit(1)
except json.JSONDecodeError as e:
print(json.dumps({"status": "error", "message": f"Invalid JSON: {e}"}))
sys.exit(1)
elif not sys.stdin.isatty():
try:
config = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(json.dumps({"status": "error", "message": f"Invalid JSON from stdin: {e}"}))
sys.exit(1)
else:
print("Usage: python3 build_infographic.py ", file=sys.stderr)
print(" cat config.json | python3 build_infographic.py", file=sys.stderr)
sys.exit(1)
errors = _validate_config(config)
if errors:
print(json.dumps({"status": "error", "errors": errors}))
sys.exit(1)
title = config.get("title", "Infographic")
subtitle = config.get("subtitle", "")
footer = config.get("footer", "")
ig_type = config.get("type", "stats")
palette = _pick_palette(config.get("palette", "auto"), config.get("data", {}))
data = config.get("data", {})
if ig_type == "stats":
body = _build_stats(data, palette)
elif ig_type == "comparison":
body = _build_comparison(data, palette)
elif ig_type == "flow":
body = _build_flow(data, palette)
elif ig_type == "dashboard":
body = _build_dashboard(data, palette)
else:
body = _build_stats(data, palette)
html = _wrap_html(title, subtitle, body, footer, palette)
output_path = config.get("output", "infographic.html")
out_dir = os.path.dirname(os.path.abspath(output_path))
os.makedirs(out_dir, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(html)
result = {
"status": "success",
"output": os.path.abspath(output_path),
"type": ig_type,
"title": title,
"palette": config.get("palette", "auto"),
"size_bytes": len(html.encode("utf-8")),
}
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()