Files
2026-04-28 22:50:53 -04:00

232 lines
8.2 KiB
Python

"""Inline SVG rendering helpers."""
from __future__ import annotations
from html import escape
from math import cos, pi, sin
from pipelines.web.repository import ChartSeries, RadarSeries
SERIES_STYLES = (
{
"color": "#009e73",
"dasharray": None,
"marker": "circle",
},
{
"color": "#0072b2",
"dasharray": "10 6",
"marker": "square",
},
{
"color": "#e69f00",
"dasharray": "4 5",
"marker": "diamond",
},
{
"color": "#cc79a7",
"dasharray": "14 5 3 5",
"marker": "triangle",
},
)
def render_score_history_svg(series: list[ChartSeries]) -> str:
"""Render a responsive inline SVG score history chart."""
width = 880
height = 300
margin_left = 70
margin_right = 28
margin_top = 24
margin_bottom = 48
plot_width = width - margin_left - margin_right
plot_height = height - margin_top - margin_bottom
all_years = sorted({point.year for item in series for point in item.points})
if not all_years:
return _empty_svg(width, height, "No score history for this selection")
min_year = min(all_years)
max_year = max(all_years)
year_span = max(max_year - min_year, 1)
def x_for(year: int) -> float:
return margin_left + ((year - min_year) / year_span) * plot_width
def y_for(score: int) -> float:
return margin_top + ((100 - score) / 100) * plot_height
parts: list[str] = [
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="Score history chart" class="score-chart">',
'<rect width="100%" height="100%" fill="transparent" />',
]
for score in (0, 25, 50, 75, 100):
y = y_for(score)
parts.append(
f'<line x1="{margin_left}" y1="{y:.2f}" x2="{width - margin_right}" y2="{y:.2f}" class="chart-grid" />'
)
parts.append(
f'<text x="{margin_left - 16}" y="{y + 4:.2f}" text-anchor="end" class="chart-axis-label">{score}</text>'
)
tick_years = _tick_years(all_years)
for year in tick_years:
x = x_for(year)
parts.append(
f'<line x1="{x:.2f}" y1="{margin_top}" x2="{x:.2f}" y2="{height - margin_bottom}" class="chart-year-line" />'
)
parts.append(
f'<text x="{x:.2f}" y="{height - 18}" text-anchor="middle" class="chart-axis-label">{year}</text>'
)
parts.append(
f'<line x1="{margin_left}" y1="{height - margin_bottom}" x2="{width - margin_right}" y2="{height - margin_bottom}" class="chart-axis" />'
)
parts.append(
f'<line x1="{margin_left}" y1="{margin_top}" x2="{margin_left}" y2="{height - margin_bottom}" class="chart-axis" />'
)
for index, item in enumerate(series):
points = sorted(item.points, key=lambda point: point.year)
if not points:
continue
style = SERIES_STYLES[index % len(SERIES_STYLES)]
color = style["color"]
path = " ".join(
f"{'M' if point_index == 0 else 'L'} {x_for(point.year):.2f} {y_for(point.score):.2f}"
for point_index, point in enumerate(points)
)
label = escape(item.label)
dash_attr = (
f' stroke-dasharray="{style["dasharray"]}"'
if style["dasharray"]
else ""
)
parts.append(
f'<path d="{path}" fill="none" stroke="{color}" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"{dash_attr}>'
f"<title>{label}</title></path>"
)
for point in points:
parts.append(
_point_marker(
marker=style["marker"],
x=x_for(point.year),
y=y_for(point.score),
color=color,
label=f"{label}: {point.score:.0f} in {point.year}",
)
)
last = points[-1]
parts.append(
f'<text x="{x_for(last.year) - 10:.2f}" y="{y_for(last.score) + 4:.2f}" text-anchor="end" class="chart-series-label" fill="{color}">'
f"{last.score:.0f}</text>"
)
parts.append("</svg>")
return "".join(parts)
def _empty_svg(width: int, height: int, message: str) -> str:
return (
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="{escape(message)}" class="score-chart">'
'<rect width="100%" height="100%" fill="transparent" />'
f'<text x="{width / 2}" y="{height / 2}" text-anchor="middle" class="chart-empty">{escape(message)}</text>'
"</svg>"
)
def _tick_years(years: list[int]) -> list[int]:
first = years[0]
last = years[-1]
start = first - (first % 5)
tick_years = {year for year in range(start, last + 1, 5) if first <= year <= last}
tick_years.add(first)
tick_years.add(last)
return sorted(tick_years)
def render_compare_radar_svg(topics: list[str], series: list[RadarSeries]) -> str:
"""Render a server-side radar chart for legislator comparison."""
width = 720
height = 560
center_x = 285
center_y = 280
radius = 200
if len(topics) < 3 or not series:
return _empty_svg(width, height, "Choose at least 3 axes and 1 legislator")
axis_count = len(topics)
def point_for(index: int, score: float) -> tuple[float, float]:
angle = -pi / 2 + (2 * pi * index / axis_count)
distance = radius * max(0, min(score, 100)) / 100
return center_x + cos(angle) * distance, center_y + sin(angle) * distance
def ring_points(score: float) -> str:
return " ".join(
f"{point_for(index, score)[0]:.2f},{point_for(index, score)[1]:.2f}"
for index in range(axis_count)
)
parts: list[str] = [
f'<svg viewBox="0 0 {width} {height}" role="img" aria-label="Compare legislators radar chart" class="radar-chart">',
'<rect width="100%" height="100%" fill="transparent" />',
]
for ring in (25, 50, 75, 100):
parts.append(f'<polygon points="{ring_points(ring)}" class="radar-ring" />')
for index, topic in enumerate(topics):
outer_x, outer_y = point_for(index, 100)
label_x, label_y = point_for(index, 113)
parts.append(
f'<line x1="{center_x}" y1="{center_y}" x2="{outer_x:.2f}" y2="{outer_y:.2f}" class="radar-axis" />'
)
anchor = "middle"
if label_x < center_x - 24:
anchor = "end"
elif label_x > center_x + 24:
anchor = "start"
parts.append(
f'<text x="{label_x:.2f}" y="{label_y:.2f}" text-anchor="{anchor}" class="radar-label">{escape(topic)}</text>'
)
for index, item in enumerate(series):
color = SERIES_STYLES[index % len(SERIES_STYLES)]["color"]
points = " ".join(
f"{point_for(topic_index, item.scores_by_topic.get(topic, 50.0))[0]:.2f},"
f"{point_for(topic_index, item.scores_by_topic.get(topic, 50.0))[1]:.2f}"
for topic_index, topic in enumerate(topics)
)
label = escape(item.legislator.display_name)
parts.append(
f'<polygon points="{points}" fill="{color}" fill-opacity="0.14" stroke="{color}" stroke-width="3" class="radar-series">'
f"<title>{label}</title></polygon>"
)
parts.append("</svg>")
return "".join(parts)
def _point_marker(*, marker: str, x: float, y: float, color: str, label: str) -> str:
title = f"<title>{escape(label)}</title>"
if marker == "square":
return (
f'<rect x="{x - 4.25:.2f}" y="{y - 4.25:.2f}" width="8.5" height="8.5" '
f'fill="{color}" rx="1.5" ry="1.5">{title}</rect>'
)
if marker == "diamond":
points = (
f"{x:.2f},{y - 5.2:.2f} "
f"{x + 5.2:.2f},{y:.2f} "
f"{x:.2f},{y + 5.2:.2f} "
f"{x - 5.2:.2f},{y:.2f}"
)
return f'<polygon points="{points}" fill="{color}">{title}</polygon>'
if marker == "triangle":
points = (
f"{x:.2f},{y - 5.5:.2f} "
f"{x + 5.5:.2f},{y + 4.5:.2f} "
f"{x - 5.5:.2f},{y + 4.5:.2f}"
)
return f'<polygon points="{points}" fill="{color}">{title}</polygon>'
return f'<circle cx="{x:.2f}" cy="{y:.2f}" r="4.5" fill="{color}">{title}</circle>'