{"spec_id":"horizon-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nhorizon-basic: Horizon Chart\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 93/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\n\n\n# Temporarily remove current directory from path to avoid name collision\n# with this file (pygal.py) shadowing the real \"pygal\" package on import.\n_cwd = sys.path[0] if sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\n# Restore path\nsys.path.insert(0, _cwd)\n\n# Theme configuration (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nRULE = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\n# imprint_div endpoints (diverging, meaningful midpoint = the sector's own\n# background) — never ColorBrewer / viridis / any other named cmap.\nDIV_NEGATIVE = \"#AE3030\"\nDIV_POSITIVE = \"#4467A3\"\nDIV_MIDPOINT = PAGE_BG\n\n\ndef _lerp_hex(c0, c1, t):\n    \"\"\"Interpolate two hex colors — Imprint has no built-in cmap API, so\n    continuous bands are built manually from the two imprint_div endpoints.\"\"\"\n    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))\n    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))\n    r, g, b = (int(round(a + (b - a) * t)) for a, b in ((r0, r1), (g0, g1), (b0, b1)))\n    return f\"#{r:02X}{g:02X}{b:02X}\"\n\n\nclass HorizonChart(Graph):\n    \"\"\"Custom Horizon Chart for pygal — folds signed deviations into\n    imprint_div color bands. Pygal has no native horizon chart type, so this\n    subclasses Graph and draws directly onto the SVG canvas (the documented\n    pygal mechanism for a chart type outside the stock catalog).\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.series_data = kwargs.pop(\"series_data\", {})\n        self.time_labels = kwargs.pop(\"time_labels\", [])\n        self.n_bands = kwargs.pop(\"n_bands\", 3)\n        self.pos_colors = kwargs.pop(\"pos_colors\", [])\n        self.neg_colors = kwargs.pop(\"neg_colors\", [])\n        super().__init__(*args, **kwargs)\n\n    def _plot(self):\n        \"\"\"Draw the horizon chart: one row per series, folded bands per cell.\"\"\"\n        if not self.series_data:\n            return\n\n        series_names = list(self.series_data.keys())\n        n_series = len(series_names)\n        n_points = len(self.time_labels)\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        # Layout margins tuned for the 3200x1800 canvas — margin_left must\n        # fit the longest series label (\"Consumer Discretionary\") at\n        # label_font_size without overflowing past the canvas edge.\n        margin_left = 520\n        margin_right = 50\n        margin_top = 140\n        margin_bottom = 130\n\n        available_width = plot_width - margin_left - margin_right\n        available_height = plot_height - margin_top - margin_bottom\n\n        row_height = available_height / n_series\n        band_gap = row_height * 0.10\n        actual_row_height = row_height - band_gap\n        cell_width = available_width / n_points\n\n        x_offset = self.view.x(0) + margin_left\n        y_offset = self.view.y(n_series) + margin_top\n\n        plot_node = self.nodes[\"plot\"]\n        horizon_group = self.svg.node(plot_node, class_=\"horizon-chart\")\n\n        # Global min/max drives the fold — every row shares one scale so\n        # band color intensity is comparable sector-to-sector.\n        all_values = [v for values in self.series_data.values() for v in values]\n        global_max = max(abs(v) for v in all_values)\n        band_size = global_max / self.n_bands\n\n        label_font_size = min(34, int(actual_row_height * 0.34))\n\n        for i, series_name in enumerate(series_names):\n            values = self.series_data[series_name]\n            row_y = y_offset + i * row_height\n\n            # Zebra striping for row-to-row scan-ability\n            bg_rect = self.svg.node(\n                horizon_group, \"rect\", x=x_offset, y=row_y, width=available_width, height=actual_row_height, rx=4\n            )\n            bg_rect.set(\"fill\", ELEVATED_BG if i % 2 == 0 else PAGE_BG)\n            bg_rect.set(\"stroke\", RULE)\n            bg_rect.set(\"stroke-width\", \"1.5\")\n\n            # Series (sector) label\n            text_node = self.svg.node(\n                horizon_group, \"text\", x=x_offset - 22, y=row_y + actual_row_height / 2 + label_font_size * 0.32\n            )\n            text_node.set(\"text-anchor\", \"end\")\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{label_font_size}px;font-weight:600;font-family:sans-serif\")\n            text_node.text = series_name\n\n            # Horizon bands, one folded stack per time point\n            for j, value in enumerate(values):\n                cell_x = x_offset + j * cell_width\n                is_positive = value >= 0\n                remaining = abs(value)\n\n                for band_idx in range(self.n_bands):\n                    band_value = min(remaining, band_size)\n                    if band_value <= 0:\n                        break\n\n                    height_ratio = band_value / band_size\n                    band_height = (actual_row_height / self.n_bands) * height_ratio\n                    band_y = row_y + actual_row_height - (actual_row_height / self.n_bands) * (band_idx + height_ratio)\n                    color = (self.pos_colors if is_positive else self.neg_colors)[band_idx]\n\n                    rect = self.svg.node(\n                        horizon_group, \"rect\", x=cell_x, y=band_y, width=cell_width + 0.5, height=band_height, rx=3\n                    )\n                    rect.set(\"fill\", color)\n                    rect.set(\"stroke\", \"none\")\n\n                    # Real SVG hover tooltip (native browser behavior in the\n                    # interactive HTML output — not a simulated/fake one).\n                    tip = self.svg.node(rect, \"title\")\n                    tip.text = f\"{series_name} · {self.time_labels[j]}: {value:+.1f}pp vs benchmark\"\n\n                    remaining -= band_size\n\n        # Subtle vertical grid at regular intervals for time readability\n        grid_interval = max(1, n_points // 12)\n        for j in range(0, n_points + 1, grid_interval):\n            grid_x = x_offset + j * cell_width\n            line = self.svg.node(\n                horizon_group, \"line\", x1=grid_x, y1=y_offset, x2=grid_x, y2=y_offset + n_series * row_height\n            )\n            line.set(\"stroke\", RULE)\n            line.set(\"stroke-width\", \"1\")\n            line.set(\"stroke-dasharray\", \"4,4\")\n\n        # X-axis tick labels\n        x_label_font_size = 30\n        label_interval = max(1, n_points // 12)\n        for j in range(0, n_points, label_interval):\n            label_x = x_offset + j * cell_width + cell_width / 2\n            label_y = y_offset + n_series * row_height + 42\n\n            text_node = self.svg.node(horizon_group, \"text\", x=label_x, y=label_y)\n            text_node.set(\"text-anchor\", \"middle\")\n            text_node.set(\"fill\", INK_MUTED)\n            text_node.set(\"style\", f\"font-size:{x_label_font_size}px;font-family:sans-serif\")\n            text_node.text = self.time_labels[j]\n\n        # X-axis title\n        x_title_font_size = 40\n        text_node = self.svg.node(\n            horizon_group, \"text\", x=x_offset + available_width / 2, y=y_offset + n_series * row_height + 95\n        )\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", INK_SOFT)\n        text_node.set(\"style\", f\"font-size:{x_title_font_size}px;font-weight:600;font-family:sans-serif\")\n        text_node.text = \"Trading Day (2024)\"\n\n        # Diverging color-scale legend (imprint_div): a single compact strip\n        # from full red (strong underperformance) through the neutral\n        # benchmark swatch to full blue (strong outperformance).\n        swatch = 52\n        gap = 12\n        n_neg = len(self.neg_colors)\n        n_pos = len(self.pos_colors)\n        stops = list(reversed(self.neg_colors)) + [None] + self.pos_colors\n        legend_width = len(stops) * (swatch + gap) - gap\n        legend_x = x_offset + available_width - legend_width\n        legend_y = self.view.y(n_series) + 48\n        legend_font_size = 32\n\n        for k, color in enumerate(stops):\n            sx = legend_x + k * (swatch + gap)\n            rect = self.svg.node(horizon_group, \"rect\", x=sx, y=legend_y, width=swatch, height=swatch, rx=5)\n            if color is None:\n                rect.set(\"fill\", ELEVATED_BG)\n                rect.set(\"stroke\", INK_MUTED)\n                rect.set(\"stroke-width\", \"1.5\")\n            else:\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", \"none\")\n\n        # Center each label under its own color group (not the whole legend\n        # bar) so a wider, more legible font never collides across groups.\n        neg_block_width = n_neg * (swatch + gap) - gap\n        pos_block_start = legend_x + (n_neg + 1) * (swatch + gap)\n        pos_block_width = n_pos * (swatch + gap) - gap\n        neg_center_x = legend_x + neg_block_width / 2\n        pos_center_x = pos_block_start + pos_block_width / 2\n\n        left_label = self.svg.node(horizon_group, \"text\", x=neg_center_x, y=legend_y - 16)\n        left_label.set(\"text-anchor\", \"middle\")\n        left_label.set(\"fill\", INK_MUTED)\n        left_label.set(\"style\", f\"font-size:{legend_font_size}px;font-family:sans-serif\")\n        left_label.text = \"Underperform\"\n\n        right_label = self.svg.node(horizon_group, \"text\", x=pos_center_x, y=legend_y - 16)\n        right_label.set(\"text-anchor\", \"middle\")\n        right_label.set(\"fill\", INK_MUTED)\n        right_label.set(\"style\", f\"font-size:{legend_font_size}px;font-family:sans-serif\")\n        right_label.text = \"Outperform\"\n\n    def _compute(self):\n        \"\"\"Establish the data-space box for view.x()/view.y() scaling.\"\"\"\n        n_series = len(self.series_data) if self.series_data else 1\n        n_points = len(self.time_labels) if self.time_labels else 1\n        self._box.xmin = 0\n        self._box.xmax = n_points\n        self._box.ymin = 0\n        self._box.ymax = n_series\n\n\n# Data: cumulative sector performance vs. a market benchmark over one\n# trading quarter (realistic, non-controversial finance scenario; a\n# different time window and domain than the sibling 24h/seed-42 server\n# metrics used elsewhere in the catalog).\nnp.random.seed(42)\n\ntrading_days = pd.bdate_range(\"2024-01-02\", periods=126)\ntime_labels = [d.strftime(\"%b %d\") for d in trading_days]\nn_points = len(time_labels)\n\n# (sector, daily drift pp, daily volatility pp) — each sector's cumulative\n# excess return vs. the benchmark is a drifted random walk.\nsector_params = [\n    (\"Technology\", 0.14, 1.3),\n    (\"Consumer Discretionary\", 0.09, 1.1),\n    (\"Financials\", 0.07, 0.9),\n    (\"Industrials\", 0.05, 0.8),\n    (\"Healthcare\", 0.04, 0.7),\n    (\"Utilities\", -0.03, 0.5),\n    (\"Real Estate\", -0.06, 1.0),\n    (\"Energy\", -0.08, 1.4),\n]\n\nsector_returns = {}\nfor sector_name, drift, volatility in sector_params:\n    daily_excess_return = np.random.normal(drift, volatility, n_points)\n    sector_returns[sector_name] = np.cumsum(daily_excess_return).tolist()\n\n# Rank rows by final cumulative excess return (best performer on top) so the\n# chart reads as a leaderboard — a clearer focal point than declaration order.\nsector_returns = dict(sorted(sector_returns.items(), key=lambda kv: kv[1][-1], reverse=True))\n\nn_bands = 3\npos_colors = [_lerp_hex(DIV_MIDPOINT, DIV_POSITIVE, (i + 1) / n_bands) for i in range(n_bands)]\nneg_colors = [_lerp_hex(DIV_MIDPOINT, DIV_NEGATIVE, (i + 1) / n_bands) for i in range(n_bands)]\n\n# Title — scale fontsize down if the descriptive prefix pushes past the\n# 67-char baseline the style guide's default (66) is tuned for.\ntitle = \"Sector Performance vs Benchmark · horizon-basic · python · pygal · anyplot.ai\"\ntitle_font_size = round(66 * min(1.0, 67 / len(title)))\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=(DIV_POSITIVE,),\n    title_font_size=title_font_size,\n    legend_font_size=44,\n    label_font_size=56,\n    major_label_font_size=44,\n    value_font_size=36,\n    font_family=\"sans-serif\",\n)\n\nchart = HorizonChart(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    series_data=sector_returns,\n    time_labels=time_labels,\n    n_bands=n_bands,\n    pos_colors=pos_colors,\n    neg_colors=neg_colors,\n    show_legend=False,\n    margin=50,\n    margin_top=50,\n    margin_bottom=50,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\n# Dummy series to trigger the render pipeline (_plot draws everything else)\nchart.add(\"\", [0])\n\n# Save outputs\nchart.render_to_file(f\"plot-{THEME}.html\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n"}