{"spec_id":"horizon-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nhorizon-basic: Horizon Chart\nLibrary: letsplot 4.11.0 | Python 3.13.15\nQuality: 84/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport shutil\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette (first series ALWAYS #009E73)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\n\n# Data - Server metrics over 7 days for multiple servers\nnp.random.seed(42)\n\nn_points = 168  # 7 days of hourly data\nn_series = 6\nseries_names = [\"Server A\", \"Server B\", \"Server C\", \"Server D\", \"Server E\", \"Server F\"]\n\n# Create time series data\nhours = np.arange(n_points)\n\n# Generate realistic CPU usage deviation data with different patterns per server\ndata_records = []\nfor i, name in enumerate(series_names):\n    # Base sinusoidal pattern with phase shift per server (daily cycle)\n    base = 18 * np.sin(2 * np.pi * hours / 24 + i * np.pi / 3)\n    # Add weekly pattern\n    weekly = 10 * np.sin(2 * np.pi * hours / 168 + i * 0.5)\n    # Add noise\n    noise = np.random.randn(n_points) * 1.5\n    # Add occasional spikes\n    spikes = np.zeros(n_points)\n    spike_indices = np.random.choice(n_points, size=4, replace=False)\n    spikes[spike_indices] = np.random.choice([-1, 1], size=4) * np.random.uniform(12, 20, size=4)\n    raw = base + weekly + noise + spikes\n    # Light rolling smoothing so folded bands read as clean intensity\n    # mountains instead of a jagged sawtooth\n    values = pd.Series(raw).rolling(window=5, center=True, min_periods=1).mean().to_numpy()\n\n    for h, val in zip(hours, values, strict=True):\n        data_records.append({\"hour\": h, \"value\": val, \"series\": name})\n\ndf = pd.DataFrame(data_records)\n\n# Order facets by descending volatility so the most erratic server leads the grid\nseries_order = df.groupby(\"series\")[\"value\"].std().sort_values(ascending=False).index.tolist()\n\n# Horizon chart parameters - fold values into bands\nn_bands = 3\nmax_val = df[\"value\"].abs().max()\nband_size = max_val / n_bands\n\n# Create horizon-folded data for visualization\n# Each band clips values to its range and overlays them\nhorizon_records = []\n\n# Band labels for legend\nband_labels = {\"pos0\": \"+Low\", \"pos1\": \"+Medium\", \"pos2\": \"+High\", \"neg0\": \"-Low\", \"neg1\": \"-Medium\", \"neg2\": \"-High\"}\n\nfor series in series_order:\n    series_data = df[df[\"series\"] == series]\n    values = series_data[\"value\"].values\n    hours_arr = series_data[\"hour\"].values\n\n    for band in range(n_bands):\n        low = band * band_size\n\n        # Process positive values\n        pos = np.clip(np.maximum(values, 0) - low, 0, band_size)\n        # Process negative values (mirror to positive for display)\n        neg = np.clip(np.maximum(-values, 0) - low, 0, band_size)\n\n        for h, pv, nv in zip(hours_arr, pos, neg, strict=True):\n            if pv > 0.01:\n                horizon_records.append({\"hour\": h, \"value\": pv, \"series\": series, \"band\": f\"pos{band}\"})\n            if nv > 0.01:\n                horizon_records.append({\"hour\": h, \"value\": nv, \"series\": series, \"band\": f\"neg{band}\"})\n\nhorizon_df = pd.DataFrame(horizon_records)\nhorizon_df[\"series\"] = pd.Categorical(horizon_df[\"series\"], categories=series_order, ordered=True)\n\n# Band intensity colors: tints interpolated from the Imprint semantic anchors\n# (brand green = positive/gain, brand blue = negative/loss) toward white, so\n# band colors stay theme-independent while intensity still reads clearly.\n# Blue is used instead of red for the negative direction to avoid a\n# red-green color-vision-deficiency pairing against the brand-green positive.\nbrand_rgb = tuple(int(IMPRINT[0][i : i + 2], 16) for i in (1, 3, 5))\nloss_rgb = tuple(int(IMPRINT[2][i : i + 2], 16) for i in (1, 3, 5))\n\ncolors = {}\nfor band_idx in range(n_bands):\n    frac = (band_idx + 1) / n_bands\n    pos_rgb = tuple(round(255 + (brand_rgb[c] - 255) * frac) for c in range(3))\n    neg_rgb = tuple(round(255 + (loss_rgb[c] - 255) * frac) for c in range(3))\n    colors[f\"pos{band_idx}\"] = \"#{:02X}{:02X}{:02X}\".format(*pos_rgb)\n    colors[f\"neg{band_idx}\"] = \"#{:02X}{:02X}{:02X}\".format(*neg_rgb)\n\n# Distinctive lets-plot feature: custom tooltip content (unavailable in plotnine)\nhorizon_tooltips = layer_tooltips().title(\"@series\").format(\"@value\", \".1f\").line(\"Band|@band\").line(\"Deviation|@value\")\n\n# Create the horizon chart\nplot = (\n    ggplot(horizon_df, aes(x=\"hour\", y=\"value\", fill=\"band\"))\n    + geom_area(position=\"identity\", alpha=0.85, color=PAGE_BG, size=0.1, tooltips=horizon_tooltips)\n    + scale_fill_manual(values=colors, labels=band_labels)\n    # Stack every series as a single, full-width, thin horizontal strip\n    # (one row per series) rather than a large multi-column grid — this is\n    # the defining \"minimize vertical space\" trait of a horizon chart.\n    + facet_grid(y=\"series\", y_order=0)\n    # Only the meaningful zero baseline is labeled per row — with six thin\n    # stacked strips, a top+bottom tick on every row would collide with the\n    # neighboring row's ticks.\n    + scale_y_continuous(breaks=[0], labels=[\"0\"])\n    + scale_x_continuous(breaks=[0, 24, 48, 72, 96, 120, 144], labels=[\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"])\n    + labs(\n        title=\"horizon-basic · python · letsplot · anyplot.ai\",\n        x=\"Day of Week\",\n        y=\"Folded Value (stacked bands)\",\n        fill=\"Band Intensity\",\n    )\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_border=element_blank(),\n        panel_grid_major=element_line(color=INK_SOFT, size=0.2),\n        panel_grid_minor=element_blank(),\n        panel_spacing_y=3,\n        strip_spacing_y=3,\n        plot_title=element_text(size=20, face=\"bold\", color=INK),\n        axis_title=element_text(size=14, color=INK),\n        axis_text_x=element_text(size=11, color=INK_SOFT),\n        axis_text_y=element_text(size=9, color=INK_SOFT),\n        axis_line=element_line(color=INK_SOFT, size=0.3),\n        strip_text=element_text(size=10, face=\"bold\", color=INK),\n        legend_position=\"right\",\n        legend_background=element_rect(fill=PAGE_BG, color=INK_SOFT),\n        legend_title=element_text(size=12, face=\"bold\", color=INK),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_key_size=12,\n    )\n    + ggsize(800, 450)\n)\n\n# Save as PNG (scale 4x for 3200x1800)\nggsave(plot, f\"plot-{THEME}.png\", scale=4)\n\n# Save as HTML for interactive version\nggsave(plot, f\"plot-{THEME}.html\")\n\n# Move files from lets-plot-images subdirectory to current directory\nif os.path.exists(\"lets-plot-images\"):\n    for filename in [f\"plot-{THEME}.png\", f\"plot-{THEME}.html\"]:\n        src = os.path.join(\"lets-plot-images\", filename)\n        if os.path.exists(src):\n            shutil.move(src, filename)\n    if not os.listdir(\"lets-plot-images\"):\n        shutil.rmtree(\"lets-plot-images\")\n"}