"""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'', '', ] for score in (0, 25, 50, 75, 100): y = y_for(score) parts.append( f'' ) parts.append( f'{score}' ) tick_years = _tick_years(all_years) for year in tick_years: x = x_for(year) parts.append( f'' ) parts.append( f'{year}' ) parts.append( f'' ) parts.append( f'' ) 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'' f"{label}" ) 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'' f"{last.score:.0f}" ) parts.append("") return "".join(parts) def _empty_svg(width: int, height: int, message: str) -> str: return ( f'' '' f'{escape(message)}' "" ) 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'', '', ] for ring in (25, 50, 75, 100): parts.append(f'') 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'' ) anchor = "middle" if label_x < center_x - 24: anchor = "end" elif label_x > center_x + 24: anchor = "start" parts.append( f'{escape(topic)}' ) 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'' f"{label}" ) parts.append("") return "".join(parts) def _point_marker(*, marker: str, x: float, y: float, color: str, label: str) -> str: title = f"{escape(label)}" if marker == "square": return ( f'{title}' ) 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'{title}' 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'{title}' return f'{title}'