adding website
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""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>'
|
||||
Reference in New Issue
Block a user