232 lines
8.2 KiB
Python
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>'
|