{"spec_id":"star-chart-constellation","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nstar-chart-constellation: Star Chart with Constellations\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme-adaptive chrome (Imprint palette)\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\"\n\n# Imprint palette\nBRAND = \"#009E73\"  # brand green — brightest stars / first series\nBLUE = \"#4467A3\"  # dim stars / constellation lines\nAMBER = \"#DDCC77\"  # warning/focal anchor — Summer Triangle highlight\n\n# Data - Stars and constellations for a northern sky view\nnp.random.seed(42)\n\n# Major constellation stars with RA (hours) and Dec (degrees)\nstars_data = [\n    # Orion\n    (\"Betelgeuse\", 5.92, 7.41, 0.42, \"Ori\"),\n    (\"Rigel\", 5.24, -8.20, 0.13, \"Ori\"),\n    (\"Bellatrix\", 5.42, 6.35, 1.64, \"Ori\"),\n    (\"Mintaka\", 5.53, -0.30, 2.23, \"Ori\"),\n    (\"Alnilam\", 5.60, -1.20, 1.69, \"Ori\"),\n    (\"Alnitak\", 5.68, -1.94, 1.77, \"Ori\"),\n    (\"Saiph\", 5.80, -9.67, 2.09, \"Ori\"),\n    # Ursa Major (Big Dipper)\n    (\"Dubhe\", 11.06, 61.75, 1.79, \"UMa\"),\n    (\"Merak\", 11.03, 56.38, 2.37, \"UMa\"),\n    (\"Phecda\", 11.90, 53.69, 2.44, \"UMa\"),\n    (\"Megrez\", 12.26, 57.03, 3.31, \"UMa\"),\n    (\"Alioth\", 12.90, 55.96, 1.77, \"UMa\"),\n    (\"Mizar\", 13.40, 54.93, 2.27, \"UMa\"),\n    (\"Alkaid\", 13.79, 49.31, 1.86, \"UMa\"),\n    # Cassiopeia\n    (\"Schedar\", 0.68, 56.54, 2.23, \"Cas\"),\n    (\"Caph\", 0.15, 59.15, 2.27, \"Cas\"),\n    (\"Gamma Cas\", 0.95, 60.72, 2.47, \"Cas\"),\n    (\"Ruchbah\", 1.36, 60.24, 2.68, \"Cas\"),\n    (\"Segin\", 1.91, 63.67, 3.37, \"Cas\"),\n    # Leo\n    (\"Regulus\", 10.14, 11.97, 1.35, \"Leo\"),\n    (\"Denebola\", 11.82, 14.57, 2.14, \"Leo\"),\n    (\"Algieba\", 10.33, 19.84, 2.28, \"Leo\"),\n    (\"Zosma\", 11.24, 20.52, 2.56, \"Leo\"),\n    (\"Chertan\", 11.24, 15.43, 3.34, \"Leo\"),\n    # Cygnus\n    (\"Deneb\", 20.69, 45.28, 1.25, \"Cyg\"),\n    (\"Sadr\", 20.37, 40.26, 2.20, \"Cyg\"),\n    (\"Gienah Cyg\", 20.77, 33.97, 2.46, \"Cyg\"),\n    (\"Albireo\", 19.51, 27.96, 3.08, \"Cyg\"),\n    (\"Delta Cyg\", 19.75, 45.13, 2.87, \"Cyg\"),\n    # Lyra\n    (\"Vega\", 18.62, 38.78, 0.03, \"Lyr\"),\n    (\"Sheliak\", 18.83, 33.36, 3.45, \"Lyr\"),\n    (\"Sulafat\", 18.98, 32.69, 3.24, \"Lyr\"),\n    # Gemini\n    (\"Castor\", 7.58, 31.89, 1.58, \"Gem\"),\n    (\"Pollux\", 7.76, 28.03, 1.14, \"Gem\"),\n    (\"Alhena\", 6.63, 16.40, 1.93, \"Gem\"),\n    (\"Wasat\", 7.07, 21.98, 3.53, \"Gem\"),\n    (\"Mebsuta\", 6.73, 25.13, 2.98, \"Gem\"),\n    # Taurus\n    (\"Aldebaran\", 4.60, 16.51, 0.85, \"Tau\"),\n    (\"Elnath\", 5.44, 28.61, 1.65, \"Tau\"),\n    (\"Alcyone\", 3.79, 24.11, 2.87, \"Tau\"),\n    (\"Tianguan\", 5.63, 21.14, 3.00, \"Tau\"),\n    # Bootes\n    (\"Arcturus\", 14.26, 19.18, -0.05, \"Boo\"),\n    (\"Izar\", 14.75, 27.07, 2.37, \"Boo\"),\n    (\"Muphrid\", 13.91, 18.40, 2.68, \"Boo\"),\n    (\"Nekkar\", 15.03, 40.39, 3.50, \"Boo\"),\n    # Aquila\n    (\"Altair\", 19.85, 8.87, 0.76, \"Aql\"),\n    (\"Tarazed\", 19.77, 10.61, 2.72, \"Aql\"),\n    (\"Alshain\", 19.92, 6.41, 3.71, \"Aql\"),\n    # Corona Borealis\n    (\"Alphecca\", 15.58, 26.71, 2.23, \"CrB\"),\n    (\"Nusakan\", 15.46, 29.11, 3.68, \"CrB\"),\n    (\"Gamma CrB\", 15.71, 26.30, 3.84, \"CrB\"),\n    (\"Delta CrB\", 15.83, 26.07, 4.63, \"CrB\"),\n    (\"Epsilon CrB\", 15.96, 26.88, 4.15, \"CrB\"),\n    # Hercules\n    (\"Kornephoros\", 16.50, 21.49, 2.77, \"Her\"),\n    (\"Zeta Her\", 16.69, 31.60, 2.81, \"Her\"),\n    (\"Eta Her\", 16.71, 38.92, 3.49, \"Her\"),\n    (\"Pi Her\", 17.25, 36.81, 3.16, \"Her\"),\n    (\"Epsilon Her\", 17.00, 30.93, 3.92, \"Her\"),\n    (\"Delta Her\", 17.25, 24.84, 3.14, \"Her\"),\n    # Draco\n    (\"Eltanin\", 17.94, 51.49, 2.23, \"Dra\"),\n    (\"Rastaban\", 17.51, 52.30, 2.79, \"Dra\"),\n    (\"Grumium\", 17.89, 56.87, 3.75, \"Dra\"),\n    (\"Thuban\", 14.07, 64.38, 3.65, \"Dra\"),\n    # Perseus\n    (\"Mirfak\", 3.41, 49.86, 1.80, \"Per\"),\n    (\"Algol\", 3.14, 40.96, 2.12, \"Per\"),\n    (\"Zeta Per\", 3.90, 31.88, 2.85, \"Per\"),\n    (\"Epsilon Per\", 3.96, 40.01, 2.89, \"Per\"),\n    # Auriga\n    (\"Capella\", 5.27, 46.00, 0.08, \"Aur\"),\n    (\"Menkalinan\", 5.99, 44.95, 1.90, \"Aur\"),\n    (\"Theta Aur\", 5.99, 37.21, 2.62, \"Aur\"),\n]\n\n# Background filler stars (dim field) — magnitude threshold keeps clutter down\nn_filler = 200\nfiller_ra = np.random.uniform(0, 24, n_filler)\nfiller_dec = np.random.uniform(-20, 75, n_filler)\nfiller_mag = np.random.uniform(3.5, 5.5, n_filler)\n\nstars = pd.DataFrame(stars_data, columns=[\"star_id\", \"ra\", \"dec\", \"magnitude\", \"constellation\"])\nfiller = pd.DataFrame(\n    {\n        \"star_id\": [f\"HIP{i}\" for i in range(n_filler)],\n        \"ra\": filler_ra,\n        \"dec\": filler_dec,\n        \"magnitude\": filler_mag,\n        \"constellation\": \"field\",\n    }\n)\nstars = pd.concat([stars, filler], ignore_index=True)\n\n# Stereographic projection from north celestial pole (inline, no helper function)\nra_rad = np.radians(stars[\"ra\"].values * 15.0)\ndec_rad = np.radians(stars[\"dec\"].values)\nr = np.cos(dec_rad) / (1.0 + np.sin(dec_rad))\nstars[\"proj_x\"] = r * np.sin(ra_rad)\nstars[\"proj_y\"] = -r * np.cos(ra_rad)\n\n# Invert magnitude for sizing: brighter stars (lower magnitude) get larger points\nmag_min, mag_max = stars[\"magnitude\"].min(), stars[\"magnitude\"].max()\nstars[\"size\"] = ((mag_max - stars[\"magnitude\"]) / (mag_max - mag_min)) * 600 + 30\n\nnamed_stars = stars[stars[\"constellation\"] != \"field\"].copy()\nfield_stars = stars[stars[\"constellation\"] == \"field\"].copy()\n\n# Constellation line edges (pairs of star_id)\nedges_list = [\n    # Orion\n    (\"Betelgeuse\", \"Bellatrix\"),\n    (\"Bellatrix\", \"Mintaka\"),\n    (\"Mintaka\", \"Alnilam\"),\n    (\"Alnilam\", \"Alnitak\"),\n    (\"Betelgeuse\", \"Alnitak\"),\n    (\"Bellatrix\", \"Rigel\"),\n    (\"Betelgeuse\", \"Saiph\"),\n    (\"Rigel\", \"Saiph\"),\n    (\"Mintaka\", \"Saiph\"),\n    # Ursa Major (Big Dipper)\n    (\"Dubhe\", \"Merak\"),\n    (\"Merak\", \"Phecda\"),\n    (\"Phecda\", \"Megrez\"),\n    (\"Megrez\", \"Alioth\"),\n    (\"Alioth\", \"Mizar\"),\n    (\"Mizar\", \"Alkaid\"),\n    (\"Megrez\", \"Dubhe\"),\n    # Cassiopeia\n    (\"Caph\", \"Schedar\"),\n    (\"Schedar\", \"Gamma Cas\"),\n    (\"Gamma Cas\", \"Ruchbah\"),\n    (\"Ruchbah\", \"Segin\"),\n    # Leo\n    (\"Regulus\", \"Chertan\"),\n    (\"Chertan\", \"Zosma\"),\n    (\"Zosma\", \"Denebola\"),\n    (\"Regulus\", \"Algieba\"),\n    (\"Algieba\", \"Zosma\"),\n    # Cygnus (Northern Cross)\n    (\"Deneb\", \"Sadr\"),\n    (\"Sadr\", \"Gienah Cyg\"),\n    (\"Gienah Cyg\", \"Albireo\"),\n    (\"Sadr\", \"Delta Cyg\"),\n    # Lyra\n    (\"Vega\", \"Sheliak\"),\n    (\"Sheliak\", \"Sulafat\"),\n    (\"Sulafat\", \"Vega\"),\n    # Gemini\n    (\"Castor\", \"Pollux\"),\n    (\"Pollux\", \"Wasat\"),\n    (\"Wasat\", \"Alhena\"),\n    (\"Castor\", \"Mebsuta\"),\n    # Taurus\n    (\"Aldebaran\", \"Tianguan\"),\n    (\"Tianguan\", \"Elnath\"),\n    (\"Aldebaran\", \"Alcyone\"),\n    # Bootes\n    (\"Arcturus\", \"Izar\"),\n    (\"Arcturus\", \"Muphrid\"),\n    (\"Izar\", \"Nekkar\"),\n    # Aquila\n    (\"Altair\", \"Tarazed\"),\n    (\"Altair\", \"Alshain\"),\n    # Corona Borealis\n    (\"Epsilon CrB\", \"Alphecca\"),\n    (\"Alphecca\", \"Nusakan\"),\n    (\"Alphecca\", \"Gamma CrB\"),\n    (\"Gamma CrB\", \"Delta CrB\"),\n    (\"Delta CrB\", \"Epsilon CrB\"),\n    # Hercules (keystone)\n    (\"Kornephoros\", \"Zeta Her\"),\n    (\"Zeta Her\", \"Eta Her\"),\n    (\"Eta Her\", \"Pi Her\"),\n    (\"Pi Her\", \"Epsilon Her\"),\n    (\"Epsilon Her\", \"Delta Her\"),\n    (\"Delta Her\", \"Kornephoros\"),\n    # Draco\n    (\"Eltanin\", \"Rastaban\"),\n    (\"Rastaban\", \"Grumium\"),\n    (\"Grumium\", \"Thuban\"),\n    # Perseus\n    (\"Mirfak\", \"Algol\"),\n    (\"Mirfak\", \"Epsilon Per\"),\n    (\"Epsilon Per\", \"Zeta Per\"),\n    # Auriga\n    (\"Capella\", \"Menkalinan\"),\n    (\"Menkalinan\", \"Theta Aur\"),\n]\n\n# Build edge dataframe with projected coordinates\nstar_lookup = stars.set_index(\"star_id\")[[\"proj_x\", \"proj_y\"]].to_dict(\"index\")\nedge_rows = []\nfor s1, s2 in edges_list:\n    if s1 in star_lookup and s2 in star_lookup:\n        edge_rows.append(\n            {\n                \"x\": star_lookup[s1][\"proj_x\"],\n                \"y\": star_lookup[s1][\"proj_y\"],\n                \"x2\": star_lookup[s2][\"proj_x\"],\n                \"y2\": star_lookup[s2][\"proj_y\"],\n            }\n        )\nedges_df = pd.DataFrame(edge_rows)\n\n# Constellation label positions (centroid of named stars in projected space)\nlabel_df = named_stars.groupby(\"constellation\").agg(proj_x=(\"proj_x\", \"mean\"), proj_y=(\"proj_y\", \"mean\")).reset_index()\nconstellation_names = {\n    \"Ori\": \"Orion\",\n    \"UMa\": \"Ursa Major\",\n    \"Cas\": \"Cassiopeia\",\n    \"Leo\": \"Leo\",\n    \"Cyg\": \"Cygnus\",\n    \"Lyr\": \"Lyra\",\n    \"Gem\": \"Gemini\",\n    \"Tau\": \"Taurus\",\n    \"Boo\": \"Boötes\",\n    \"Aql\": \"Aquila\",\n    \"CrB\": \"Corona Bor.\",\n    \"Her\": \"Hercules\",\n    \"Dra\": \"Draco\",\n    \"Per\": \"Perseus\",\n    \"Aur\": \"Auriga\",\n}\nlabel_df[\"name\"] = label_df[\"constellation\"].map(constellation_names)\n\n# Custom label offsets to eliminate overlap in crowded regions\n# (Hercules / Corona Borealis pushed further apart vs. previous attempt)\nlabel_offsets = {\n    \"Cas\": (0.06, -0.12),\n    \"Per\": (-0.06, 0.10),\n    \"UMa\": (0.16, -0.12),\n    \"Lyr\": (-0.15, -0.07),\n    \"Cyg\": (0.10, 0.06),\n    \"Aur\": (0.10, 0.05),\n    \"Aql\": (-0.12, 0.08),\n    \"Her\": (0.17, 0.19),\n    \"CrB\": (0.07, -0.20),\n    \"Dra\": (-0.10, -0.08),\n    \"Boo\": (-0.10, 0.08),\n    \"Tau\": (0.10, -0.05),\n}\nfor abbr, (dx, dy) in label_offsets.items():\n    mask = label_df[\"constellation\"] == abbr\n    label_df.loc[mask, \"proj_x\"] += dx\n    label_df.loc[mask, \"proj_y\"] += dy\n\n# Highlight the Summer Triangle asterism (Vega, Deneb, Altair) as a storytelling focal point\nsummer_triangle_stars = [\"Vega\", \"Deneb\", \"Altair\"]\nst_lookup = {s: star_lookup[s] for s in summer_triangle_stars}\ntriangle_edges = pd.DataFrame(\n    [\n        {\n            \"x\": st_lookup[\"Vega\"][\"proj_x\"],\n            \"y\": st_lookup[\"Vega\"][\"proj_y\"],\n            \"x2\": st_lookup[\"Deneb\"][\"proj_x\"],\n            \"y2\": st_lookup[\"Deneb\"][\"proj_y\"],\n        },\n        {\n            \"x\": st_lookup[\"Deneb\"][\"proj_x\"],\n            \"y\": st_lookup[\"Deneb\"][\"proj_y\"],\n            \"x2\": st_lookup[\"Altair\"][\"proj_x\"],\n            \"y2\": st_lookup[\"Altair\"][\"proj_y\"],\n        },\n        {\n            \"x\": st_lookup[\"Altair\"][\"proj_x\"],\n            \"y\": st_lookup[\"Altair\"][\"proj_y\"],\n            \"x2\": st_lookup[\"Vega\"][\"proj_x\"],\n            \"y2\": st_lookup[\"Vega\"][\"proj_y\"],\n        },\n    ]\n)\n# Summer Triangle label at centroid\nst_cx = np.mean([st_lookup[s][\"proj_x\"] for s in summer_triangle_stars])\nst_cy = np.mean([st_lookup[s][\"proj_y\"] for s in summer_triangle_stars])\nst_label_df = pd.DataFrame([{\"proj_x\": st_cx, \"proj_y\": st_cy + 0.04}])\n\n# Declination circles for the grid (projected as circles on the stereographic plane)\ndec_circles_data = []\nfor dec_val in [0, 30, 60]:\n    theta = np.linspace(0, 2 * np.pi, 120)\n    dec_r = np.radians(dec_val)\n    circ_r = np.cos(dec_r) / (1.0 + np.sin(dec_r))\n    for i in range(len(theta)):\n        dec_circles_data.append(\n            {\"gx\": circ_r * np.sin(theta[i]), \"gy\": -circ_r * np.cos(theta[i]), \"dec_label\": f\"{dec_val}°\", \"order\": i}\n        )\ndec_circles_df = pd.DataFrame(dec_circles_data)\n\n# RA radial lines for grid\nra_lines_data = []\nfor ra_h in range(0, 24, 3):\n    ra_angle = np.radians(ra_h * 15.0)\n    r_inner = np.cos(np.radians(60)) / (1.0 + np.sin(np.radians(60)))\n    r_outer = np.cos(np.radians(-10)) / (1.0 + np.sin(np.radians(-10)))\n    ra_lines_data.append(\n        {\n            \"x\": r_inner * np.sin(ra_angle),\n            \"y\": -r_inner * np.cos(ra_angle),\n            \"x2\": r_outer * np.sin(ra_angle),\n            \"y2\": -r_outer * np.cos(ra_angle),\n            \"ra_label\": f\"{ra_h}h\",\n        }\n    )\nra_lines_df = pd.DataFrame(ra_lines_data)\n\n# RA labels at the boundary\nra_label_df = ra_lines_df.copy()\nra_label_df[\"lx\"] = ra_label_df[\"x2\"] * 1.07\nra_label_df[\"ly\"] = ra_label_df[\"y2\"] * 1.07\n\n# Magnitude legend data (size classes, lower-right inside the plot)\nlegend_stars = pd.DataFrame(\n    [\n        {\"lx\": 0.92, \"ly\": -0.72, \"lsize\": 620, \"label\": \"mag < 1\"},\n        {\"lx\": 0.92, \"ly\": -0.80, \"lsize\": 330, \"label\": \"mag 1-2.5\"},\n        {\"lx\": 0.92, \"ly\": -0.88, \"lsize\": 140, \"label\": \"mag 2.5-4\"},\n        {\"lx\": 0.92, \"ly\": -0.96, \"lsize\": 45, \"label\": \"mag 4-6\"},\n    ]\n)\n\n# Boundary circle at dec=-10° to frame the projection\nboundary_theta = np.linspace(0, 2 * np.pi, 200)\nboundary_dec = np.radians(-10)\nboundary_r = np.cos(boundary_dec) / (1.0 + np.sin(boundary_dec))\nboundary_df = pd.DataFrame(\n    {\"bx\": boundary_r * np.sin(boundary_theta), \"by\": -boundary_r * np.cos(boundary_theta), \"order\": range(200)}\n)\n\n# Plot domain\nplot_bound = 1.22\n\n# Shared magnitude→size scale (brighter = larger)\nsize_scale = alt.Scale(domain=[stars[\"size\"].min(), stars[\"size\"].max()], range=[12, 700])\n\n# Interactive selection for constellation highlighting\nhighlight = alt.selection_point(fields=[\"constellation\"], on=\"pointerover\", empty=False)\n\nX_AXIS = alt.X(\n    \"proj_x:Q\",\n    axis=alt.Axis(labels=False, ticks=False, domain=False, title=\"\", grid=False),\n    scale=alt.Scale(domain=[-plot_bound, plot_bound]),\n)\nY_AXIS = alt.Y(\n    \"proj_y:Q\",\n    axis=alt.Axis(labels=False, ticks=False, domain=False, title=\"\", grid=False),\n    scale=alt.Scale(domain=[-plot_bound, plot_bound]),\n)\n\n# Boundary circle layer\nboundary = (\n    alt.Chart(boundary_df)\n    .mark_line(strokeWidth=1.2, opacity=0.4)\n    .encode(x=\"bx:Q\", y=\"by:Q\", order=\"order:Q\", color=alt.value(INK_SOFT))\n)\n\n# RA radial grid lines\nra_grid = (\n    alt.Chart(ra_lines_df)\n    .mark_rule(strokeDash=[4, 6], strokeWidth=0.8, opacity=0.28)\n    .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\", color=alt.value(INK_SOFT))\n)\n\n# Declination grid circles\ndec_grid = (\n    alt.Chart(dec_circles_df)\n    .mark_line(strokeDash=[4, 6], strokeWidth=0.8, opacity=0.28)\n    .encode(x=\"gx:Q\", y=\"gy:Q\", detail=\"dec_label:N\", order=\"order:Q\", color=alt.value(INK_SOFT))\n)\n\n# RA hour labels around the boundary (made more prominent per prior review)\nra_labels = (\n    alt.Chart(ra_label_df)\n    .mark_text(fontSize=12, opacity=0.7)\n    .encode(x=\"lx:Q\", y=\"ly:Q\", text=\"ra_label:N\", color=alt.value(INK_SOFT))\n)\n\n# Field (filler) stars — muted, recede behind the data\nfield_points = (\n    alt.Chart(field_stars)\n    .mark_circle(opacity=0.55)\n    .encode(x=X_AXIS, y=Y_AXIS, size=alt.Size(\"size:Q\", legend=None, scale=size_scale), color=alt.value(INK_MUTED))\n)\n\n# Constellation stick-figure lines (Imprint blue, semi-transparent)\nlines = (\n    alt.Chart(edges_df)\n    .mark_rule(strokeWidth=1.6, opacity=0.5)\n    .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\", color=alt.value(BLUE))\n)\n\n# Summer Triangle highlight lines (dashed amber — focal anchor)\ntriangle_lines = (\n    alt.Chart(triangle_edges)\n    .mark_rule(strokeWidth=2.0, strokeDash=[6, 4], opacity=0.9)\n    .encode(x=\"x:Q\", y=\"y:Q\", x2=\"x2:Q\", y2=\"y2:Q\", color=alt.value(AMBER))\n)\n\n# Named constellation stars — brand green, sized by magnitude (brighter = larger).\n# Solid brand keeps the stars clearly distinct from the blue stick-figure lines.\nstar_points = (\n    alt.Chart(named_stars)\n    .mark_circle(color=BRAND, stroke=PAGE_BG, strokeWidth=0.6)\n    .encode(\n        x=X_AXIS,\n        y=Y_AXIS,\n        size=alt.Size(\"size:Q\", legend=None, scale=size_scale),\n        opacity=alt.condition(highlight, alt.value(1.0), alt.value(0.95)),\n        tooltip=[\"star_id:N\", \"magnitude:Q\", \"constellation:N\", \"ra:Q\", \"dec:Q\"],\n    )\n    .add_params(highlight)\n)\n\n# Constellation labels (primary ink, theme-adaptive)\nlabels = (\n    alt.Chart(label_df)\n    .mark_text(fontSize=14, fontWeight=\"bold\", dy=-20, opacity=0.95)\n    .encode(x=\"proj_x:Q\", y=\"proj_y:Q\", text=\"name:N\", color=alt.value(INK))\n)\n\n# Summer Triangle label\ntriangle_label = (\n    alt.Chart(st_label_df)\n    .mark_text(fontSize=13, fontStyle=\"italic\", fontWeight=\"bold\", opacity=0.95)\n    .encode(x=\"proj_x:Q\", y=\"proj_y:Q\", text=alt.value(\"Summer Triangle\"), color=alt.value(AMBER))\n)\n\n# Magnitude legend - star markers (brand green)\nlegend_points = (\n    alt.Chart(legend_stars)\n    .mark_circle(opacity=0.95, color=BRAND)\n    .encode(\n        x=alt.X(\"lx:Q\", scale=alt.Scale(domain=[-plot_bound, plot_bound])),\n        y=alt.Y(\"ly:Q\", scale=alt.Scale(domain=[-plot_bound, plot_bound])),\n        size=alt.Size(\"lsize:Q\", legend=None, scale=size_scale),\n    )\n)\n\n# Magnitude legend - text labels\nlegend_text = (\n    alt.Chart(legend_stars)\n    .mark_text(fontSize=13, align=\"left\", dx=20, opacity=0.85)\n    .encode(x=\"lx:Q\", y=\"ly:Q\", text=\"label:N\", color=alt.value(INK_SOFT))\n)\n\n# Legend title\nlegend_title_df = pd.DataFrame([{\"lx\": 0.92, \"ly\": -0.62}])\nlegend_title = (\n    alt.Chart(legend_title_df)\n    .mark_text(fontSize=14, fontWeight=\"bold\", align=\"left\", opacity=0.95)\n    .encode(x=\"lx:Q\", y=\"ly:Q\", text=alt.value(\"Magnitude\"), color=alt.value(INK))\n)\n\n# Combine all layers\nchart = (\n    (\n        boundary\n        + ra_grid\n        + dec_grid\n        + ra_labels\n        + lines\n        + triangle_lines\n        + field_points\n        + star_points\n        + labels\n        + triangle_label\n        + legend_points\n        + legend_text\n        + legend_title\n    )\n    .properties(\n        width=480,\n        height=480,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=\"star-chart-constellation · python · altair · anyplot.ai\",\n            subtitle=\"Stereographic projection from the north celestial pole · Summer Triangle highlighted\",\n            fontSize=16,\n            subtitleFontSize=12,\n            anchor=\"middle\",\n            color=INK,\n            subtitleColor=INK_SOFT,\n            offset=12,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(grid=False, domainColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n    .configure_title(color=INK)\n)\n\n# Save PNG (square target) + interactive HTML\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad-only to the exact square canvas target (no crop — see altair library prompt)\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}