{"spec_id":"star-chart-constellation","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nstar-chart-constellation: Star Chart with Constellations\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-17\n\"\"\"\n\nimport math\nimport os\nimport sys\n\n\n# Remove this file's directory from sys.path so `import pygal` resolves to the\n# installed package, not this script (which shares the same name).\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _this_dir]\n\nimport xml.etree.ElementTree as ET\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\nnp.random.seed(42)\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\nGRID = \"#CFCDC4\" if THEME == \"light\" else \"#3A3934\"  # subtle solid graticule\n\n# Imprint sequential cmap (single-polarity): brand green -> blue.\n# Magnitude is continuous, so tiers sample this ramp; brightest tier == #009E73.\nSEQ_LO = \"#009E73\"\nSEQ_HI = \"#4467A3\"\nseq_lo = tuple(int(SEQ_LO[i : i + 2], 16) for i in (1, 3, 5))\nseq_hi = tuple(int(SEQ_HI[i : i + 2], 16) for i in (1, 3, 5))\nOCHRE = \"#BD8233\"  # Imprint position 4 — ecliptic = the Sun's warm path\n\n# Azimuthal-equidistant projection from the North Celestial Pole. This gives the\n# natural circular sky boundary the spec asks for: Polaris sits at the centre,\n# declination parallels become concentric circles, RA meridians become radial\n# spokes. Radius = angular distance from the pole (90 - dec) in degrees.\nBOUNDARY_DEC = -30.0  # outer rim of the chart (declination floor)\nR_MAX = 90.0 - BOUNDARY_DEC  # 120\n\n\ndef project(ra_h, dec_deg):\n    \"\"\"RA (hours) + Dec (degrees) -> planar (x, y) on the equidistant disk.\"\"\"\n    r = 90.0 - dec_deg\n    az = math.radians(ra_h * 15.0)\n    return r * math.sin(az), r * math.cos(az)\n\n\ndef project_polar(r, ra_h):\n    \"\"\"Radius + RA -> planar (x, y) (for graticule/label placement).\"\"\"\n    az = math.radians(ra_h * 15.0)\n    return r * math.sin(az), r * math.cos(az)\n\n\n# Constellation data: stars as (name, RA_hours, Dec_degrees, apparent_magnitude).\n# Real catalog coordinates/magnitudes. Path order traces stick-figure lines\n# without lifting pen (nodes may repeat where the asterism branches).\nconstellations = {\n    \"Orion\": [\n        (\"Betelgeuse\", 5.92, 7.41, 0.42),\n        (\"Bellatrix\", 5.42, 6.35, 1.64),\n        (\"Mintaka\", 5.53, -0.30, 2.23),\n        (\"Alnilam\", 5.60, -1.20, 1.69),\n        (\"Alnitak\", 5.68, -1.94, 1.77),\n        (\"Saiph\", 5.80, -9.67, 2.09),\n        (\"Rigel\", 5.24, -8.20, 0.13),\n    ],\n    \"Ursa Major\": [\n        (\"Alkaid\", 13.79, 49.31, 1.86),\n        (\"Mizar\", 13.40, 54.93, 2.27),\n        (\"Alioth\", 12.90, 55.96, 1.77),\n        (\"Megrez\", 12.26, 57.03, 3.31),\n        (\"Dubhe\", 11.06, 61.75, 1.79),\n        (\"Merak\", 11.03, 56.38, 2.37),\n        (\"Phecda\", 11.90, 53.69, 2.44),\n        (\"Megrez\", 12.26, 57.03, 3.31),\n    ],\n    \"Ursa Minor\": [\n        (\"Polaris\", 2.53, 89.26, 1.98),\n        (\"Yildun\", 17.54, 86.59, 4.36),\n        (\"Epsilon UMi\", 16.77, 82.04, 4.23),\n        (\"Zeta UMi\", 15.73, 77.79, 4.32),\n        (\"Kochab\", 14.85, 74.16, 2.08),\n        (\"Pherkad\", 15.35, 71.83, 3.05),\n    ],\n    \"Cassiopeia\": [\n        (\"Segin\", 1.91, 63.67, 3.35),\n        (\"Ruchbah\", 1.43, 60.24, 2.68),\n        (\"Gamma Cas\", 0.95, 60.72, 2.47),\n        (\"Schedar\", 0.68, 56.54, 2.24),\n        (\"Caph\", 0.15, 59.15, 2.28),\n    ],\n    \"Cepheus\": [\n        (\"Alderamin\", 21.31, 62.59, 2.45),\n        (\"Alfirk\", 21.48, 70.56, 3.23),\n        (\"Errai\", 23.66, 77.63, 3.21),\n        (\"Iota Cep\", 22.83, 66.20, 3.50),\n        (\"Zeta Cep\", 22.18, 58.20, 3.35),\n        (\"Alderamin\", 21.31, 62.59, 2.45),\n    ],\n    \"Draco\": [\n        (\"Eltanin\", 17.94, 51.49, 2.23),\n        (\"Rastaban\", 17.51, 52.30, 2.79),\n        (\"Altais\", 19.21, 67.66, 3.07),\n        (\"Aldhibah\", 17.15, 65.71, 3.17),\n        (\"Edasich\", 15.42, 58.97, 3.29),\n        (\"Thuban\", 14.07, 64.38, 3.65),\n    ],\n    \"Cygnus\": [\n        (\"Deneb\", 20.69, 45.28, 1.25),\n        (\"Sadr\", 20.37, 40.26, 2.23),\n        (\"Gienah\", 20.77, 33.97, 2.46),\n        (\"Sadr\", 20.37, 40.26, 2.23),\n        (\"Delta Cyg\", 19.75, 45.13, 2.87),\n        (\"Sadr\", 20.37, 40.26, 2.23),\n        (\"Albireo\", 19.51, 27.96, 3.18),\n    ],\n    \"Lyra\": [\n        (\"Vega\", 18.62, 38.78, 0.03),\n        (\"Zeta Lyr\", 18.75, 37.60, 4.36),\n        (\"Sheliak\", 18.83, 33.36, 3.52),\n        (\"Sulafat\", 18.98, 32.69, 3.25),\n        (\"Zeta Lyr\", 18.75, 37.60, 4.36),\n    ],\n    \"Aquila\": [\n        (\"Tarazed\", 19.77, 10.61, 2.72),\n        (\"Altair\", 19.85, 8.87, 0.76),\n        (\"Alshain\", 19.92, 6.41, 3.71),\n        (\"Altair\", 19.85, 8.87, 0.76),\n        (\"Zeta Aql\", 19.09, 13.86, 2.99),\n    ],\n    \"Hercules\": [\n        (\"Kornephoros\", 16.50, 21.49, 2.78),\n        (\"Zeta Her\", 16.69, 31.60, 2.81),\n        (\"Eta Her\", 16.71, 38.92, 3.48),\n        (\"Pi Her\", 17.25, 36.81, 3.16),\n        (\"Zeta Her\", 16.69, 31.60, 2.81),\n    ],\n    \"Bootes\": [\n        (\"Muphrid\", 13.91, 18.40, 2.68),\n        (\"Arcturus\", 14.26, 19.18, -0.05),\n        (\"Izar\", 14.75, 27.07, 2.35),\n        (\"Seginus\", 14.53, 38.31, 3.04),\n        (\"Nekkar\", 15.03, 40.39, 3.49),\n    ],\n    \"Corona Borealis\": [\n        (\"Theta CrB\", 15.55, 31.36, 4.14),\n        (\"Nusakan\", 15.46, 29.11, 3.66),\n        (\"Alphecca\", 15.58, 26.71, 2.22),\n        (\"Gamma CrB\", 15.71, 26.30, 3.80),\n        (\"Delta CrB\", 15.82, 26.07, 4.59),\n    ],\n    \"Leo\": [\n        (\"Ras Elased\", 10.00, 23.77, 2.98),\n        (\"Algieba\", 10.33, 19.84, 2.28),\n        (\"Regulus\", 10.14, 11.97, 1.40),\n        (\"Denebola\", 11.82, 14.57, 2.14),\n        (\"Zosma\", 11.24, 20.52, 2.56),\n        (\"Algieba\", 10.33, 19.84, 2.28),\n    ],\n    \"Cancer\": [\n        (\"Beta Cnc\", 8.28, 9.19, 3.52),\n        (\"Acubens\", 8.97, 11.86, 4.25),\n        (\"Asellus Aus\", 8.74, 18.15, 3.94),\n        (\"Asellus Bor\", 8.72, 21.47, 4.66),\n    ],\n    \"Gemini\": [\n        (\"Alhena\", 6.63, 16.40, 1.93),\n        (\"Tejat\", 6.38, 22.51, 2.88),\n        (\"Mebsuta\", 6.73, 25.13, 3.06),\n        (\"Castor\", 7.58, 31.89, 1.58),\n        (\"Pollux\", 7.76, 28.03, 1.14),\n    ],\n    \"Auriga\": [\n        (\"Capella\", 5.28, 46.00, 0.08),\n        (\"Menkalinan\", 5.99, 44.95, 1.90),\n        (\"Mahasim\", 5.99, 37.21, 2.69),\n        (\"Hassaleh\", 4.95, 33.17, 2.69),\n        (\"Capella\", 5.28, 46.00, 0.08),\n    ],\n    \"Perseus\": [\n        (\"Gamma Per\", 3.08, 53.51, 2.91),\n        (\"Mirfak\", 3.41, 49.86, 1.79),\n        (\"Delta Per\", 3.72, 47.79, 3.01),\n        (\"Epsilon Per\", 3.96, 40.01, 2.89),\n        (\"Delta Per\", 3.72, 47.79, 3.01),\n        (\"Mirfak\", 3.41, 49.86, 1.79),\n        (\"Algol\", 3.14, 40.96, 2.12),\n    ],\n    \"Taurus\": [\n        (\"Alcyone\", 3.79, 24.11, 2.87),\n        (\"Aldebaran\", 4.60, 16.51, 0.85),\n        (\"Prima Hyadum\", 4.33, 15.63, 3.65),\n        (\"Aldebaran\", 4.60, 16.51, 0.85),\n        (\"Elnath\", 5.44, 28.61, 1.65),\n        (\"Tianguan\", 5.63, 21.14, 3.00),\n    ],\n    \"Aries\": [(\"Hamal\", 2.12, 23.46, 2.00), (\"Sheratan\", 1.91, 20.81, 2.64), (\"Mesarthim\", 1.89, 19.29, 3.86)],\n    \"Triangulum\": [\n        (\"Mothallah\", 1.88, 29.58, 3.41),\n        (\"Beta Tri\", 2.16, 34.99, 3.00),\n        (\"Gamma Tri\", 2.29, 33.85, 4.01),\n        (\"Mothallah\", 1.88, 29.58, 3.41),\n    ],\n    \"Andromeda\": [\n        (\"Almach\", 2.06, 42.33, 2.10),\n        (\"Mirach\", 1.16, 35.62, 2.05),\n        (\"Delta And\", 0.66, 30.86, 3.27),\n        (\"Alpheratz\", 0.14, 29.09, 2.06),\n    ],\n    \"Pegasus\": [\n        (\"Algenib\", 0.22, 15.18, 2.83),\n        (\"Alpheratz\", 0.14, 29.09, 2.06),\n        (\"Scheat\", 23.06, 28.08, 2.42),\n        (\"Markab\", 23.08, 15.21, 2.48),\n        (\"Algenib\", 0.22, 15.18, 2.83),\n        (\"Markab\", 23.08, 15.21, 2.48),\n        (\"Enif\", 21.74, 9.88, 2.39),\n    ],\n    \"Canis Major\": [\n        (\"Mirzam\", 6.38, -17.96, 1.98),\n        (\"Sirius\", 6.75, -16.72, -1.46),\n        (\"Wezen\", 7.14, -26.39, 1.83),\n        (\"Adhara\", 6.98, -28.97, 1.50),\n        (\"Wezen\", 7.14, -26.39, 1.83),\n        (\"Aludra\", 7.40, -29.30, 2.45),\n    ],\n    \"Canis Minor\": [(\"Procyon\", 7.66, 5.22, 0.34), (\"Gomeisa\", 7.45, 8.29, 2.89)],\n}\n\n# Collect unique constellation stars for the scatter, carrying projected coords.\nseen_stars = set()\nall_stars = []  # (name, ra, dec, px, py, mag, cname)\nfor cname, star_list in constellations.items():\n    for name, ra, dec, mag in star_list:\n        key = (name, ra, dec)\n        if key not in seen_stars:\n            seen_stars.add(key)\n            px, py = project(ra, dec)\n            all_stars.append((name, ra, dec, px, py, mag, cname))\n\n# Background field stars sampled uniformly across the projected disk so the sky\n# fills evenly (area-uniform on the equidistant plane). r = R_MAX * sqrt(U).\nn_bg = 160\nbg_r = R_MAX * np.sqrt(np.random.uniform(0.0, 1.0, n_bg))\nbg_az = np.random.uniform(0.0, 2 * math.pi, n_bg)\nbg_mag = np.random.uniform(3.5, 5.5, n_bg)\nfor i in range(n_bg):\n    px = bg_r[i] * math.sin(bg_az[i])\n    py = bg_r[i] * math.cos(bg_az[i])\n    dec = 90.0 - bg_r[i]\n    ra = (math.degrees(bg_az[i]) / 15.0) % 24.0\n    all_stars.append((f\"HD {10000 + i}\", ra, dec, px, py, bg_mag[i], None))\n\n# Anchor each constellation label on its brightest star (pushed slightly radially\n# outward). Anchoring on the brightest star instead of the centroid keeps labels\n# next to a recognizable anchor and stops large pole-region constellations from\n# piling their labels at the chart centre.\ncentroids = {}\nfor cname, star_list in constellations.items():\n    name, ra, dec, _mag = min(star_list, key=lambda s: s[3])\n    bx, by = project(ra, dec)\n    norm = math.hypot(bx, by) or 1.0\n    centroids[cname] = (bx + bx / norm * 6.0, by + by / norm * 6.0)\n\n# Magnitude tiers: (legend label, mag_lo, mag_hi, dot radius). Brighter = larger.\n# Colors sampled from the Imprint sequential ramp; brightest == #009E73.\nbright_size = 28\ntiers = [\n    (\"★ Mag < 1 (brightest)\", -2.0, 1.0, bright_size),\n    (\"★ Mag 1–2\", 1.0, 2.0, 20),\n    (\"★ Mag 2–3\", 2.0, 3.0, 13),\n    (\"★ Mag 3–5.5 (faintest)\", 3.0, 6.5, 7),\n]\ntier_colors = [\n    \"#{:02X}{:02X}{:02X}\".format(\n        *(round(seq_lo[k] + (seq_hi[k] - seq_lo[k]) * (j / (len(tiers) - 1))) for k in range(3))\n    )\n    for j in range(len(tiers))\n]\n\ntier_data = {t[0]: [] for t in tiers}\nfor name, ra, dec, px, py, mag, cname in all_stars:\n    tooltip = f\"{name} (mag {mag:.1f}, RA {ra:.1f}h, Dec {dec:+.0f}°)\"\n    if cname:\n        tooltip += f\" — {cname}\"\n    for tname, lo_m, hi_m, _ in tiers:\n        if lo_m <= mag < hi_m:\n            tier_data[tname].append({\"value\": (px, py), \"label\": tooltip})\n            break\n\n# Coordinate graticule (solid, subtle): Dec parallels as concentric circles and\n# RA meridians as radial spokes — drawn ourselves so the native dashed guides\n# can be turned off in favour of the clean solid sky grid the style guide wants.\ngrid_points = []\nfor dec in (60, 30, 0, -30):\n    r = 90.0 - dec\n    for az_deg in range(0, 361, 4):\n        grid_points.append({\"value\": project_polar(r, az_deg / 15.0)})\n    grid_points.append(None)\nfor ra_h in range(0, 24, 2):\n    grid_points.append({\"value\": project_polar(5.0, ra_h)})\n    grid_points.append({\"value\": project_polar(R_MAX, ra_h)})\n    grid_points.append(None)\n\n# Constellation stick-figure lines as ONE series: a None break between each\n# constellation collapses 23 redundant legend entries into a single subtle layer.\nconst_line_points = []\nfor cname, star_list in constellations.items():\n    for name, ra, dec, _mag in star_list:\n        px, py = project(ra, dec)\n        const_line_points.append({\"value\": (px, py), \"label\": f\"{name} — {cname}\"})\n    const_line_points.append(None)\n\n# Ecliptic (the Sun's path) as a proper great circle in equatorial coordinates,\n# then projected — a dashed warm reference curve.\nobliquity = math.radians(23.44)\necliptic_points = []\nfor lon_deg in np.linspace(0, 360, 145):\n    lon = math.radians(lon_deg)\n    dec_ecl = math.degrees(math.asin(math.sin(obliquity) * math.sin(lon)))\n    ra_ecl = (math.degrees(math.atan2(math.cos(obliquity) * math.sin(lon), math.cos(lon))) / 15.0) % 24.0\n    if dec_ecl >= BOUNDARY_DEC:\n        ecliptic_points.append(\n            {\"value\": project(ra_ecl, dec_ecl), \"label\": f\"Ecliptic ({ra_ecl:.1f}h, {dec_ecl:+.0f}°)\"}\n        )\n    else:\n        ecliptic_points.append(None)\n\n# Style — theme-adaptive chrome; Imprint data colors stay constant across themes.\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=(GRID, INK_MUTED, OCHRE, *tier_colors),\n    opacity=0.9,\n    opacity_hover=1.0,\n    title_font_size=56,\n    label_font_size=44,\n    major_label_font_size=40,\n    legend_font_size=40,\n    value_font_size=34,\n    tooltip_font_size=34,\n    title_font_family=\"Trebuchet MS, Helvetica, sans-serif\",\n    label_font_family=\"Trebuchet MS, Helvetica, sans-serif\",\n    major_label_font_family=\"Trebuchet MS, Helvetica, sans-serif\",\n    legend_font_family=\"Trebuchet MS, Helvetica, sans-serif\",\n    value_font_family=\"Trebuchet MS, Helvetica, sans-serif\",\n    stroke_width=2.5,\n)\n\n# Chart configuration (Canvas hard rule: square 2400x2400 suits the circular sky).\n# xrange is widened vs range so the bottom legend's height loss does not squash\n# the projected disk into an ellipse (keeps the sky boundary round).\nchart = pygal.XY(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=\"star-chart-constellation · python · pygal · anyplot.ai\",\n    allow_interruptions=True,\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=26,\n    stroke=True,\n    dots_size=7,\n    show_x_guides=False,\n    show_y_guides=False,\n    show_x_labels=False,\n    show_y_labels=False,\n    xrange=(-154, 154),\n    range=(-139, 139),\n    margin_top=20,\n    margin_bottom=120,\n    margin_left=20,\n    margin_right=20,\n    tooltip_border_radius=6,\n    tooltip_fancy_mode=True,\n    print_values=False,\n    spacing=18,\n)\n\n# Solid coordinate graticule (drawn first = furthest back), thin and subtle.\nchart.add(\"RA / Dec grid\", grid_points, dots_size=0, stroke_style={\"width\": 1.5})\n\n# Constellation stick-figure lines (single subtle layer, behind the stars).\nchart.add(\"Constellations\", const_line_points, dots_size=0)\n\n# Ecliptic line (dashed warm reference curve).\nchart.add(\"Ecliptic\", ecliptic_points, dots_size=0, stroke_style={\"dasharray\": \"16, 12\"})\n\n# Star scatter by magnitude tier (stroke=False for dots only).\nfor tname, _, _, size in tiers:\n    chart.add(tname, tier_data[tname], dots_size=size, stroke=False)\n\n# Render SVG and add constellation + coordinate labels via XML post-processing.\nET.register_namespace(\"\", \"http://www.w3.org/2000/svg\")\nET.register_namespace(\"xlink\", \"http://www.w3.org/1999/xlink\")\nsvg_bytes = chart.render()\ntree = ET.fromstring(svg_bytes)\nns = {\"svg\": \"http://www.w3.org/2000/svg\"}\n\n# Use two brightest-star circles (r == bright_size) to build a linear data->pixel\n# map; pygal renders dots in add order, so circle order matches all_stars order.\nbright_stars_data = [(px, py) for _, _, _, px, py, mag, _ in all_stars if mag < 1.0]\ncircles = tree.findall(\".//svg:circle\", ns)\nref_circles = [(float(c.get(\"cx\")), float(c.get(\"cy\"))) for c in circles if c.get(\"r\") == str(bright_size)]\n\n\ndef add_text(group, x, y, text, size, fill, italic=False, anchor=\"middle\"):\n    el = ET.SubElement(group, \"text\")\n    el.set(\"x\", f\"{x:.1f}\")\n    el.set(\"y\", f\"{y:.1f}\")\n    el.set(\"font-family\", \"Trebuchet MS, Helvetica, sans-serif\")\n    el.set(\"font-size\", str(size))\n    el.set(\"fill\", fill)\n    el.set(\"text-anchor\", anchor)\n    if italic:\n        el.set(\"font-style\", \"italic\")\n    el.text = text\n\n\nif len(ref_circles) >= 2 and len(bright_stars_data) >= 2:\n    px1, py1 = bright_stars_data[0]\n    px2, py2 = bright_stars_data[1]\n    sx1, sy1 = ref_circles[0]\n    sx2, sy2 = ref_circles[1]\n    # Linear mapping: svg_x = a * px + b, svg_y = c * py + d\n    a = (sx1 - sx2) / (px1 - px2)\n    b = sx1 - a * px1\n    c = (sy1 - sy2) / (py1 - py2)\n    d = sy1 - c * py1\n\n    def to_svg(px, py):\n        return a * px + b, c * py + d\n\n    # Constellation name labels.\n    name_group = ET.SubElement(tree, \"g\")\n    name_group.set(\"class\", \"constellation-labels\")\n    for cname, (cx, cy) in centroids.items():\n        sx, sy = to_svg(cx, cy)\n        add_text(name_group, sx, sy, cname, 42, INK_SOFT, italic=True)\n\n    # Coordinate labels: RA hours around the rim, Dec degrees along a meridian.\n    coord_group = ET.SubElement(tree, \"g\")\n    coord_group.set(\"class\", \"coordinate-labels\")\n    for ra_h in range(0, 24, 2):\n        lx, ly = project_polar(R_MAX + 6.0, ra_h)\n        sx, sy = to_svg(lx, ly)\n        add_text(coord_group, sx, sy + 12, f\"{ra_h}h\", 38, INK_MUTED)\n    for dec in (60, 30, 0, -30):\n        lx, ly = project_polar(90.0 - dec, 10.5)\n        sx, sy = to_svg(lx, ly)\n        add_text(coord_group, sx + 6, sy, f\"{dec:+d}°\", 38, INK_MUTED, anchor=\"start\")\n\nsvg_str = ET.tostring(tree, encoding=\"unicode\")\n\n# Save interactive HTML (SVG) version.\nwith open(f\"plot-{THEME}.html\", \"w\") as f:\n    f.write(svg_str)\n\n# Convert modified SVG to PNG at the exact canvas contract size.\ncairosvg.svg2png(\n    bytestring=svg_str.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=2400, output_height=2400\n)\n"}