Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 148 additions & 107 deletions plots/area-mountain-panorama/implementations/python/altair.py
Original file line number Diff line number Diff line change
@@ -1,94 +1,128 @@
""" anyplot.ai
area-mountain-panorama: Mountain Panorama Profile with Labeled Peaks
Library: altair 6.1.0 | Python 3.14.4
Quality: 93/100 | Created: 2026-04-25
Library: altair 6.2.2 | Python 3.13.14
Quality: 84/100 | Updated: 2026-06-30
"""

import importlib
import os
import sys


# Drop script directory from sys.path so the `altair` package resolves, not this file
# Drop script directory from sys.path so `altair` resolves the package, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")
np = importlib.import_module("numpy")
pd = importlib.import_module("pandas")
from PIL import Image


# Theme tokens (chrome flips with theme; data colors stay constant)
# Theme tokens chrome flips with theme; Imprint palette data colors stay constant
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
BRAND = "#009E73" # Okabe-Ito position 1 — silhouette fill (single data series)
BRAND = "#009E73" # Imprint palette position 1 — silhouette fill (single data series)

# Theme-adaptive dusk sky gradient (chrome layer above the ridgeline; spec-authorized)
SKY_HORIZON = "#FFC58A" if THEME == "light" else "#5A3422" # warm dusk glow at ridgeline
SKY_MID = "#D89AA8" if THEME == "light" else "#2E1F35" # twilight rose / deep plum
SKY_ZENITH = "#5C5078" if THEME == "light" else "#0C0E1A" # evening blue / night sky
# Theme-adaptive dusk sky gradient (spec-authorized chrome above the ridgeline)
SKY_HORIZON = "#FFC58A" if THEME == "light" else "#5A3422"
SKY_MID = "#D89AA8" if THEME == "light" else "#2E1F35"
SKY_ZENITH = "#5C5078" if THEME == "light" else "#0C0E1A"
# Alpenglow rim — warm gold / rose-copper at the sky-to-silhouette boundary
ALPENGLOW = "#FFBA6A" if THEME == "light" else "#C88060"

# Data — Wallis (Valais, CH) panorama: 16 4000-m summits along a 180° sweep
BASE_ELEV = 2950

# All 16 major Wallis (Valais, CH) summits across a 180° horizontal sweep.
# left_slope / right_slope: m/degree for piecewise-linear tent flanks.
peaks = pd.DataFrame(
[
("Weisshorn", 4506, 9),
("Zinalrothorn", 4221, 20),
("Ober Gabelhorn", 4063, 30),
("Dent Blanche", 4358, 42),
("Matterhorn", 4478, 56),
("Breithorn", 4164, 73),
("Pollux", 4092, 81),
("Castor", 4223, 88),
("Liskamm", 4527, 97),
("Monte Rosa", 4634, 109),
("Strahlhorn", 4190, 122),
("Rimpfischhorn", 4199, 132),
("Allalinhorn", 4027, 140),
("Alphubel", 4206, 148),
("Täschhorn", 4491, 158),
("Dom", 4545, 168),
("Weisshorn", 4506, 9, 280, 200),
("Zinalrothorn", 4221, 22, 180, 250),
("Ober Gabelhorn", 4063, 30, 220, 180),
("Dent Blanche", 4358, 42, 200, 300),
("Matterhorn", 4478, 56, 350, 280), # focal point — steepest flanks
("Breithorn", 4164, 72, 150, 160),
("Pollux", 4092, 83, 200, 170),
("Castor", 4223, 88, 180, 210),
("Liskamm", 4527, 97, 200, 180),
("Monte Rosa", 4634, 109, 180, 250),
("Strahlhorn", 4190, 122, 200, 180),
("Rimpfischhorn", 4199, 130, 220, 190),
("Allalinhorn", 4027, 137, 180, 200),
("Alphubel", 4206, 148, 160, 200),
("Täschhorn", 4491, 155, 250, 180),
("Dom", 4545, 168, 200, 280),
],
columns=["name", "elevation_m", "angle_deg"],
columns=["name", "elevation_m", "angle_deg", "left_slope", "right_slope"],
)

# Skyline ridge — gaussians around named peaks plus naturalistic minor ridge texture
# Ridgeline — piecewise-linear tent/triangle functions per spec.
# Spec explicitly forbids Gaussian/bell-curve bumps; each summit uses two linear
# flanks meeting at a sharp apex, with asymmetric slope steepness.
np.random.seed(42)
angles = np.linspace(-2, 182, 1500)
ridge_elev = 2950 + 110 * np.sin(angles * 0.11) + 35 * np.sin(angles * 0.43 + 1.1)

for _ in range(55):
# Base ridge always at or above BASE_ELEV — positive-only sinusoidal undulation
ridge_elev = BASE_ELEV + np.maximum(0, 70 * np.sin(angles * 0.12) + 22 * np.sin(angles * 0.47 + 1.1))

# Rocky inter-peak jaggedness: 65 random tent functions (NOT Gaussian)
for _ in range(65):
pos = np.random.uniform(-2, 182)
height = np.random.uniform(150, 480)
width = np.random.uniform(1.4, 3.0)
ridge_elev = np.maximum(ridge_elev, 2950 + height * np.exp(-((angles - pos) ** 2) / (2 * width**2)))
height = np.random.uniform(60, 320)
lslope = np.random.uniform(60, 220)
rslope = np.random.uniform(60, 220)
tent = BASE_ELEV + height - np.where(angles <= pos, lslope * (pos - angles), rslope * (angles - pos))
ridge_elev = np.maximum(ridge_elev, np.maximum(BASE_ELEV, tent))

# Named peaks: steep asymmetric tent functions — sharp apex + linear flanks
for _, row in peaks.iterrows():
height = row["elevation_m"] - 2950
width = 2.0 + (row["elevation_m"] - 4000) * 0.0007
ridge_elev = np.maximum(ridge_elev, 2950 + height * np.exp(-((angles - row["angle_deg"]) ** 2) / (2 * width**2)))
pos, elev = row["angle_deg"], row["elevation_m"]
tent = elev - np.where(angles <= pos, row["left_slope"] * (pos - angles), row["right_slope"] * (angles - pos))
ridge_elev = np.maximum(ridge_elev, np.maximum(BASE_ELEV, tent))

ridge = pd.DataFrame({"angle_deg": angles, "elevation_m": ridge_elev})

# Stagger label heights so adjacent peaks don't collide; Matterhorn lifted as focal summit
peaks = peaks.sort_values("angle_deg").reset_index(drop=True)
LABEL_HIGH = 5800
LABEL_LOW = 5400
peaks["label_y"] = [LABEL_HIGH if i % 2 == 0 else LABEL_LOW for i in range(len(peaks))]
peaks.loc[peaks["name"] == "Matterhorn", "label_y"] = 6000
peaks["elev_label"] = peaks["elevation_m"].apply(lambda v: f"{v:.0f} m")
# Four label tiers assigned by round-robin for maximum same-tier angular separation
# (minimum ~33° within each tier, preventing label collision).
# TIER_A (5300): Weisshorn(9°), Liskamm(97°), Allalinhorn(137°)
# TIER_B (5100): Zinalrothorn(22°), Breithorn(72°), Monte Rosa(109°), Alphubel(148°)
# TIER_C (4900): Ober Gabelhorn(30°), Pollux(83°), Strahlhorn(122°), Täschhorn(155°)
# TIER_D (4700): Dent Blanche(42°), Castor(88°), Rimpfischhorn(130°), Dom(168°)
# MATTERHORN SPECIAL (5500): strongest focal accent
TIER_A, TIER_B, TIER_C, TIER_D, TIER_MAT = 5300, 5100, 4900, 4700, 5500
label_y_map = {
"Weisshorn": TIER_A,
"Zinalrothorn": TIER_B,
"Ober Gabelhorn": TIER_C,
"Dent Blanche": TIER_D,
"Matterhorn": TIER_MAT,
"Breithorn": TIER_B,
"Pollux": TIER_C,
"Castor": TIER_D,
"Liskamm": TIER_A,
"Monte Rosa": TIER_B,
"Strahlhorn": TIER_C,
"Rimpfischhorn": TIER_D,
"Allalinhorn": TIER_A,
"Alphubel": TIER_B,
"Täschhorn": TIER_C,
"Dom": TIER_D,
}
peaks["label_y"] = peaks["name"].map(label_y_map)
peaks["elev_label"] = peaks["elevation_m"].apply(lambda v: f"{v} m")

matterhorn = peaks[peaks["name"] == "Matterhorn"]
others = peaks[peaks["name"] != "Matterhorn"]

# Shared scales / axis so all layers register on the same coordinate system
# Coordinate system — only the sky layer carries the explicit scale + axis;
# other layers share it implicitly via Vega-Lite layer scale resolution.
X_SCALE = alt.Scale(domain=[0, 180])
Y_SCALE = alt.Scale(domain=[2900, 6300])
Y_SCALE = alt.Scale(domain=[2900, 5800])
Y_AXIS = alt.Axis(values=[3000, 3500, 4000, 4500, 5000])

# Sky — dusk vertical gradient covering the full plot area; silhouette will mask the lower half
sky_df = pd.DataFrame({"x_min": [0], "x_max": [180], "y_min": [2900], "y_max": [6300]})
# Layer 1: dusk sky gradient (vertical linear, zenith → ridge horizon)
sky_df = pd.DataFrame({"x_min": [0], "x_max": [180], "y_min": [2900], "y_max": [5800]})
sky = (
alt.Chart(sky_df)
.mark_rect(
Expand All @@ -113,92 +147,85 @@
)
)

# Silhouette — brand-green photo-like fill; ridge stroke gives the snow-edge alpenglow line
silhouette = (
# Layer 2: mountain silhouette — brand-green filled area below the ridgeline
silhouette = alt.Chart(ridge).mark_area(color=BRAND, opacity=1.0).encode(x="angle_deg:Q", y="elevation_m:Q")

# Layer 3: alpenglow rim — warm glowing stroke at the sky-to-silhouette boundary
alpenglow = (
alt.Chart(ridge)
.mark_area(color=BRAND, line={"color": BRAND, "strokeWidth": 2.5}, opacity=1.0)
.mark_line(color=ALPENGLOW, strokeWidth=3.5, opacity=0.88)
.encode(x="angle_deg:Q", y="elevation_m:Q")
)

# Leader lines from summit up to label position (with tooltip for HTML hover)
_tooltip = [alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")]

# Layers 4–5: leader lines from summit apex to label anchor
leaders = (
alt.Chart(others)
.mark_rule(strokeWidth=1.0, opacity=0.55, color=INK_SOFT)
.encode(
x="angle_deg:Q",
y="elevation_m:Q",
y2="label_y:Q",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.encode(x="angle_deg:Q", y="elevation_m:Q", y2="label_y:Q", tooltip=_tooltip)
)
matterhorn_leader = (
alt.Chart(matterhorn)
.mark_rule(strokeWidth=2.0, opacity=0.9, color=INK)
.encode(
x="angle_deg:Q",
y="elevation_m:Q",
y2="label_y:Q",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.mark_rule(strokeWidth=2.5, opacity=0.9, color=INK)
.encode(x="angle_deg:Q", y="elevation_m:Q", y2="label_y:Q", tooltip=_tooltip)
)

# Two-line peak labels at recommended sizes (name 18, elevation 15 — meets tick-floor)
# Layers 6–7: center-aligned name/elevation labels for all non-Matterhorn peaks
name_labels = (
alt.Chart(others)
.mark_text(align="center", baseline="bottom", fontSize=18, fontWeight="bold", color=INK, dy=-26)
.encode(
x="angle_deg:Q",
y="label_y:Q",
text="name:N",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.mark_text(align="center", baseline="bottom", fontSize=10, fontWeight="bold", color=INK, dy=-22)
.encode(x="angle_deg:Q", y="label_y:Q", text="name:N", tooltip=_tooltip)
)
elev_labels = (
alt.Chart(others)
.mark_text(align="center", baseline="bottom", fontSize=15, color=INK_SOFT, dy=-8)
.encode(
x="angle_deg:Q",
y="label_y:Q",
text="elev_label:N",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.mark_text(align="center", baseline="bottom", fontSize=10, color=INK_SOFT, dy=-6)
.encode(x="angle_deg:Q", y="label_y:Q", text="elev_label:N", tooltip=_tooltip)
)

# Matterhorn focal accent: notably larger label so the anchor summit reads as the composition's focus
# Layers 8–9: Matterhorn focal accent larger font, heavier weight, composition anchor
matterhorn_name = (
alt.Chart(matterhorn)
.mark_text(align="center", baseline="bottom", fontSize=26, fontWeight="bold", color=INK, dy=-30)
.encode(
x="angle_deg:Q",
y="label_y:Q",
text="name:N",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.mark_text(align="center", baseline="bottom", fontSize=15, fontWeight="bold", color=INK, dy=-28)
.encode(x="angle_deg:Q", y="label_y:Q", text="name:N", tooltip=_tooltip)
)
matterhorn_elev = (
alt.Chart(matterhorn)
.mark_text(align="center", baseline="bottom", fontSize=18, fontWeight="bold", color=INK_SOFT, dy=-8)
.encode(
x="angle_deg:Q",
y="label_y:Q",
text="elev_label:N",
tooltip=[alt.Tooltip("name:N", title="Peak"), alt.Tooltip("elevation_m:Q", title="Elevation (m)", format=",d")],
)
.mark_text(align="center", baseline="bottom", fontSize=12, fontWeight="bold", color=INK_SOFT, dy=-8)
.encode(x="angle_deg:Q", y="label_y:Q", text="elev_label:N", tooltip=_tooltip)
)

title_str = "Wallis Panorama · area-mountain-panorama · python · altair · anyplot.ai"
n = len(title_str)
ratio = 67 / n if n > 67 else 1.0
title_fs = max(11, round(16 * ratio))

# height=190: vl-convert with explicit Y_SCALE on the sky anchor layer adds ~47 CSS px
# of Y overhead per 190 CSS px (source height ≈ 1500px pre-pad, ≤1800px with title).
# DO NOT increase to ≥210 — overhead scales with height and tips over 1800 source px.
chart = (
(sky + silhouette + leaders + matterhorn_leader + name_labels + elev_labels + matterhorn_name + matterhorn_elev)
(
sky
+ silhouette
+ alpenglow
+ leaders
+ matterhorn_leader
+ name_labels
+ elev_labels
+ matterhorn_name
+ matterhorn_elev
)
.properties(
width=1600,
height=900,
width=620,
height=190,
title=alt.Title(
"Wallis Panorama · area-mountain-panorama · altair · anyplot.ai",
title_str,
subtitle="Sixteen 4000-m summits along a 180° horizontal sweep, Valais Alps",
subtitleColor=INK_SOFT,
subtitleFontSize=18,
fontSize=28,
subtitleFontSize=13,
fontSize=title_fs,
anchor="start",
offset=18,
offset=12,
color=INK,
),
background=PAGE_BG,
Expand All @@ -211,11 +238,25 @@
gridOpacity=0.0,
labelColor=INK_SOFT,
titleColor=INK,
labelFontSize=18,
titleFontSize=22,
tickSize=8,
labelFontSize=10,
titleFontSize=12,
tickSize=5,
)
)

chart.save(f"plot-{THEME}.png", scale_factor=3.0)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
chart.save(f"plot-{THEME}.html")

# Pad-only to exact 3200×1800 target — altair.md Canvas rule (never crop)
TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
raise SystemExit(
f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
f"Shrink chart .properties(width=, height=) values and re-render."
)
if _w < TW or _h < TH:
_canvas = Image.new("RGB", (TW, TH), PAGE_BG)
_canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
_canvas.save(f"plot-{THEME}.png")
Loading
Loading