ephemvis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+
16
+ # Secrets / local env / OS cruft
17
+ .env
18
+ *.local
19
+ .DS_Store
20
+ Thumbs.db
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to **ephemvis** are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] — 2026-07-24
8
+
9
+ Initial public release — themeable SVG rendering for openephem chart data.
10
+
11
+ ### Rendering
12
+ - `render_svg` — the natal chart wheel: zodiac ring, house cusps & numbers, planet
13
+ glyphs with radial de-collision + dotted leader lines, and aspect lines.
14
+ - `render_aspect_grid_svg` — the triangular aspectarian: a clean square grid with a
15
+ themed gradient on the frame and separators.
16
+ - 12 selectable themes, including the **infrared / ultraviolet** spectral pair, each
17
+ with a per-theme prism-halo sweep.
18
+ - Hover tooltips via SVG `<title>`: body name / sign / degree, aspect significance,
19
+ sign → house (rising-aware), and Asc / MC.
20
+
21
+ ### Design & quality
22
+ - **Zero runtime dependencies**; engine-agnostic — renders any dict with the
23
+ openephem `ChartResult` shape. The optional `[compute]` extra pulls openephem so
24
+ you can compute and render in one line.
25
+ - MIT-licensed.
26
+ - CI gates: pytest + **SVG snapshot tests** (byte-stable across Linux/Windows) +
27
+ ruff + mypy, on Python 3.10–3.13.
28
+
29
+ [0.1.0]: https://github.com/NoahChristian/ephemvis/releases/tag/v0.1.0
ephemvis-0.1.0/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elizabeth Huston, Ph.D. and Noah Christian, Ph.D.
4
+ Contact: elpisastrology@gmail.com · elpisastrology.com and noahchristian@gmail.com
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+
24
+ --------------------------------------------------------------------------------
25
+ This MIT license covers the source code authored in this project. It does NOT
26
+ cover third-party libraries or data the code depends on, which retain their own
27
+ licenses (Skyfield MIT, spiceypy/CSPICE, Moshier public domain, JPL/NASA public
28
+ domain, ESA Hipparcos, OpenStreetMap ODbL, IANA tzdata public domain, etc.).
29
+ See ephemeris-migration/oracle/references.txt for the full third-party notices.
30
+
31
+ Swiss Ephemeris (pyswisseph, AGPL) is NOT included or distributed in this repo;
32
+ it is used only by the optional offline validation tools, which require the user
33
+ to install it separately. No AGPL code or data is redistributed here.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ephemvis
3
+ Version: 0.1.0
4
+ Summary: Themeable SVG chart-wheel & aspect-grid rendering for openephem chart data
5
+ Project-URL: Homepage, https://github.com/NoahChristian/ephemvis
6
+ Project-URL: Repository, https://github.com/NoahChristian/ephemvis
7
+ Project-URL: Changelog, https://github.com/NoahChristian/ephemvis/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/NoahChristian/ephemvis/issues
9
+ Author: Elizabeth Huston, Noah Christian
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: aspectarian,astrology,chart wheel,natal chart,openephem,svg,visualization
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: compute
23
+ Requires-Dist: openephem>=0.1.0; extra == 'compute'
24
+ Provides-Extra: test
25
+ Requires-Dist: mypy>=1.10; extra == 'test'
26
+ Requires-Dist: pytest>=7; extra == 'test'
27
+ Requires-Dist: ruff>=0.6; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ephemvis
31
+
32
+ [![CI](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml/badge.svg)](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/ephemvis.svg)](https://pypi.org/project/ephemvis/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/ephemvis.svg)](https://pypi.org/project/ephemvis/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+
37
+ Themeable **SVG rendering** for astrological chart data — the display layer that
38
+ pairs with [openephem](https://github.com/NoahChristian/openephem) (the calculation
39
+ core). ephemvis turns a chart dict into a self-contained chart **wheel** and a
40
+ triangular **aspect grid**.
41
+
42
+ The split is deliberate: **openephem computes, ephemvis draws.** The math core
43
+ stays small and auditable; the visuals can be forked and restyled freely without
44
+ touching (or destabilising) the calculations.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install ephemvis # pure renderer, zero runtime deps
50
+ pip install ephemvis[compute] # also pulls openephem to compute charts
51
+ ```
52
+
53
+ ## Use
54
+
55
+ ```python
56
+ from openephem import resolve, assemble # needs ephemvis[compute]
57
+ from ephemvis import render_svg, render_aspect_grid_svg
58
+
59
+ chart = assemble(resolve(date=(1990, 5, 15), time=(14, 30),
60
+ place="New York, NY"))
61
+ open("wheel.svg", "w", encoding="utf-8").write(render_svg(chart, theme="prism"))
62
+ open("grid.svg", "w", encoding="utf-8").write(render_aspect_grid_svg(chart, theme="prism"))
63
+ ```
64
+
65
+ `render_svg` and `render_aspect_grid_svg` accept any dict with the openephem
66
+ `ChartResult` shape (`angles`, `cusps`, `bodies`, `aspects`), so **any engine that
67
+ emits that shape can be rendered** — ephemvis has no dependency on how the chart
68
+ was computed.
69
+
70
+ ## Themes
71
+
72
+ `light`, `dark`, `auto`, and eight pastel "pretty" modes with a prism-halo ring:
73
+ `prism`, `twilight`, `aurora`, `opal`, `seafoam`, `meadow`, `dawn`, `blossom`.
74
+ See `ephemvis.PALETTES`.
75
+
76
+ ## The data contract
77
+
78
+ ephemvis reads the chart dict openephem's `assemble()` returns:
79
+
80
+ ```python
81
+ {
82
+ "angles": {"asc": float, "mc": float, ...} | None, # ecliptic longitude, degrees
83
+ "cusps": [float] (12) | None, # house cusp longitudes
84
+ "bodies": {name: {"lon": float, "retro": bool}}, # ecliptic longitude, degrees
85
+ "aspects": [{"a": name, "b": name, "aspect": str, "orb": float}],
86
+ "warnings": [str],
87
+ }
88
+ ```
89
+
90
+ MIT © 2026 Elizabeth Huston, Ph.D. and Noah Christian, Ph.D.
91
+ Contact: elpisastrology@gmail.com · elpisastrology.com and noahchristian@gmail.com
@@ -0,0 +1,62 @@
1
+ # ephemvis
2
+
3
+ [![CI](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml/badge.svg)](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/ephemvis.svg)](https://pypi.org/project/ephemvis/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/ephemvis.svg)](https://pypi.org/project/ephemvis/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ Themeable **SVG rendering** for astrological chart data — the display layer that
9
+ pairs with [openephem](https://github.com/NoahChristian/openephem) (the calculation
10
+ core). ephemvis turns a chart dict into a self-contained chart **wheel** and a
11
+ triangular **aspect grid**.
12
+
13
+ The split is deliberate: **openephem computes, ephemvis draws.** The math core
14
+ stays small and auditable; the visuals can be forked and restyled freely without
15
+ touching (or destabilising) the calculations.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install ephemvis # pure renderer, zero runtime deps
21
+ pip install ephemvis[compute] # also pulls openephem to compute charts
22
+ ```
23
+
24
+ ## Use
25
+
26
+ ```python
27
+ from openephem import resolve, assemble # needs ephemvis[compute]
28
+ from ephemvis import render_svg, render_aspect_grid_svg
29
+
30
+ chart = assemble(resolve(date=(1990, 5, 15), time=(14, 30),
31
+ place="New York, NY"))
32
+ open("wheel.svg", "w", encoding="utf-8").write(render_svg(chart, theme="prism"))
33
+ open("grid.svg", "w", encoding="utf-8").write(render_aspect_grid_svg(chart, theme="prism"))
34
+ ```
35
+
36
+ `render_svg` and `render_aspect_grid_svg` accept any dict with the openephem
37
+ `ChartResult` shape (`angles`, `cusps`, `bodies`, `aspects`), so **any engine that
38
+ emits that shape can be rendered** — ephemvis has no dependency on how the chart
39
+ was computed.
40
+
41
+ ## Themes
42
+
43
+ `light`, `dark`, `auto`, and eight pastel "pretty" modes with a prism-halo ring:
44
+ `prism`, `twilight`, `aurora`, `opal`, `seafoam`, `meadow`, `dawn`, `blossom`.
45
+ See `ephemvis.PALETTES`.
46
+
47
+ ## The data contract
48
+
49
+ ephemvis reads the chart dict openephem's `assemble()` returns:
50
+
51
+ ```python
52
+ {
53
+ "angles": {"asc": float, "mc": float, ...} | None, # ecliptic longitude, degrees
54
+ "cusps": [float] (12) | None, # house cusp longitudes
55
+ "bodies": {name: {"lon": float, "retro": bool}}, # ecliptic longitude, degrees
56
+ "aspects": [{"a": name, "b": name, "aspect": str, "orb": float}],
57
+ "warnings": [str],
58
+ }
59
+ ```
60
+
61
+ MIT © 2026 Elizabeth Huston, Ph.D. and Noah Christian, Ph.D.
62
+ Contact: elpisastrology@gmail.com · elpisastrology.com and noahchristian@gmail.com
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ephemvis"
7
+ version = "0.1.0"
8
+ description = "Themeable SVG chart-wheel & aspect-grid rendering for openephem chart data"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Elizabeth Huston" },
14
+ { name = "Noah Christian" },
15
+ ]
16
+ keywords = [
17
+ "astrology", "chart wheel", "aspectarian", "svg", "visualization",
18
+ "openephem", "natal chart",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Topic :: Multimedia :: Graphics",
26
+ "Topic :: Scientific/Engineering :: Astronomy",
27
+ "Intended Audience :: Developers",
28
+ "Operating System :: OS Independent",
29
+ ]
30
+ # The renderers take a plain chart dict and have NO runtime dependencies — this is
31
+ # a pure, engine-agnostic SVG layer. Install [compute] to also pull the openephem
32
+ # calculation core for the one-liner (compute + render together).
33
+ dependencies = []
34
+
35
+ [project.optional-dependencies]
36
+ compute = ["openephem>=0.1.0"]
37
+ test = ["pytest>=7", "ruff>=0.6", "mypy>=1.10"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/NoahChristian/ephemvis"
41
+ Repository = "https://github.com/NoahChristian/ephemvis"
42
+ Changelog = "https://github.com/NoahChristian/ephemvis/blob/main/CHANGELOG.md"
43
+ Issues = "https://github.com/NoahChristian/ephemvis/issues"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/ephemvis"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["src/ephemvis", "README.md", "CHANGELOG.md", "LICENSE"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py310"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B"]
57
+ # E501: dense SVG output lines. UP031: %-formatting is deliberate here — the tight,
58
+ # many-argument SVG string assembly reads better as % than as f-strings.
59
+ ignore = ["E501", "UP031"]
60
+
61
+ [tool.mypy]
62
+ python_version = "3.10"
63
+ files = ["src/ephemvis"]
64
+ ignore_missing_imports = true
65
+ disallow_untyped_defs = false
66
+ warn_unused_ignores = false
@@ -0,0 +1,28 @@
1
+ """ephemvis — SVG chart rendering for openephem chart data.
2
+
3
+ Turns a chart dict (the shape openephem's ``assemble()`` returns — ``angles``,
4
+ ``cusps``, ``bodies``, ``aspects``) into self-contained, themeable SVG: a chart
5
+ wheel and a triangular aspect grid. The renderers take a plain dict and have no
6
+ runtime dependencies, so any engine that emits the same shape can be rendered.
7
+
8
+ Quick start:
9
+ from openephem import resolve, assemble # optional: `pip install ephemvis[compute]`
10
+ from ephemvis import render_svg, render_aspect_grid_svg
11
+ chart = assemble(resolve(date=(1990, 5, 15), time=(14, 30), place="New York, NY"))
12
+ wheel_svg = render_svg(chart, theme="prism")
13
+ grid_svg = render_aspect_grid_svg(chart, theme="prism")
14
+
15
+ Themes: 'light', 'dark', 'auto', and the pastel modes 'prism', 'twilight',
16
+ 'aurora', 'opal', 'seafoam', 'meadow', 'dawn', 'blossom' (see PALETTES).
17
+ """
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ from . import aspectgrid, wheel # noqa: F401
22
+ from .aspectgrid import render_aspect_grid_svg # noqa: F401
23
+ from .wheel import PALETTES, render_svg # noqa: F401
24
+
25
+ __all__ = [
26
+ "__version__", "render_svg", "render_aspect_grid_svg", "PALETTES",
27
+ "wheel", "aspectgrid",
28
+ ]
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ aspectgrid.py — render the aspectarian (triangular aspect grid) as a self-contained SVG.
4
+
5
+ Takes an openephem chart dict (needs ``bodies`` and ``aspects``) and returns an SVG
6
+ string: bodies run down the diagonal as glyphs; each lower-left cell shows the aspect
7
+ between its row and column body, coloured by nature (hard / soft / neutral). Themed
8
+ via the same PALETTES as the wheel. Uses inline presentation attributes (no <style>),
9
+ so it is safe to embed in the same page as other inline SVGs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import cast
15
+
16
+ from .wheel import _L, PALETTES, PLANET_GLYPHS, _esc, _lerp_hex, body_label
17
+
18
+ ASP_SYM = {"conjunction": "☌", "opposition": "☍", "square": "□", "trine": "△",
19
+ "sextile": "⚹", "quincunx": "⚻", "semisextile": "⚺",
20
+ "semisquare": "∠", "sesquiquadrate": "⚼", "quintile": "Q"}
21
+ ASP_CAT = {"conjunction": "aNeutral", "opposition": "aHard", "square": "aHard",
22
+ "semisquare": "aHard", "sesquiquadrate": "aHard", "trine": "aSoft",
23
+ "sextile": "aSoft", "quincunx": "aSoft", "semisextile": "aSoft", "quintile": "aSoft"}
24
+ ASP_LABEL = {"conjunction": "Conjunction", "opposition": "Opposition", "square": "Square",
25
+ "trine": "Trine", "sextile": "Sextile", "quincunx": "Quincunx",
26
+ "semisextile": "Semi-sextile", "semisquare": "Semi-square",
27
+ "sesquiquadrate": "Sesquiquadrate", "quintile": "Quintile"}
28
+ # relational verb for the hover significance ("Sun conjunct Mercury ...")
29
+ ASP_REL = {"conjunction": "conjunct", "opposition": "opposite", "square": "square",
30
+ "trine": "trine", "sextile": "sextile", "quincunx": "quincunx",
31
+ "semisextile": "semi-sextile", "semisquare": "semi-square",
32
+ "sesquiquadrate": "sesquiquadrate", "quintile": "quintile"}
33
+ _NICE = {"TrueNode": "North Node", "MeanNode": "North Node",
34
+ "MeanLilith": "Lilith", "OscuLilith": "Lilith", "SouthNode": "South Node"}
35
+ DEFAULT_ORDER = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus",
36
+ "Neptune", "Pluto", "TrueNode", "MeanNode", "MeanLilith", "OscuLilith",
37
+ "Chiron", "Ceres", "Pallas", "Juno", "Vesta",
38
+ "Astraea", "Hygeia", "Eros", "Eris", "Sedna", "AsteroidLilith"]
39
+ SYM = '"Segoe UI Symbol","Noto Sans Symbols2","Apple Symbols",system-ui,sans-serif'
40
+ # Themes whose two grid-gradient colours read better swapped (perceptual). The
41
+ # direction/anchoring is unchanged — only which colour sits at the bottom-left nexus.
42
+ _GRID_GRAD_REVERSE = {"infrared"}
43
+
44
+
45
+ def render_aspect_grid_svg(chart: dict, theme: str = "light", order=None,
46
+ cell: float = 30.0, legend: bool = True) -> str:
47
+ """Return an SVG string for the triangular aspect grid.
48
+
49
+ Only bodies that appear in `order` AND in the chart's `bodies` are shown, in that
50
+ order. Aspects are matched from `chart['aspects']` (undirected).
51
+ """
52
+ bodies = chart.get("bodies") or {}
53
+ aspects = chart.get("aspects") or []
54
+ pal = _L if theme == "auto" else PALETTES.get(theme, _L)
55
+ gorder = [b for b in (order or DEFAULT_ORDER) if b in bodies]
56
+ n = len(gorder)
57
+ amap = {frozenset((a.get("a"), a.get("b"))): a for a in aspects}
58
+ C, pad = float(cell), 8.0
59
+ bg0 = cast("tuple[str, str]", pal["bg"])[0]
60
+ diagbg = _lerp_hex(bg0, pal["planet"], 0.10) # subtle diagonal-cell tint over bg
61
+ accent = pal["anglelab"] # diagonal glyph colour
62
+ muted = pal["deg"]
63
+ # grid gradient: two theme colours, nexus at the bottom-left corner
64
+ gwarm, gcool = pal["aHard"], pal["aSoft"]
65
+ if theme in _GRID_GRAD_REVERSE: # perceptual per-theme swap
66
+ gwarm, gcool = gcool, gwarm
67
+ gid = "aspgrid_%s_%d_%d" % (theme, int(round(C)), n)
68
+
69
+ present = [k for k in ASP_SYM if any(a.get("aspect") == k for a in aspects)]
70
+ leg_h = 26.0 if (legend and present) else 0.0
71
+ gridW = n * C
72
+ legW = sum(20.0 + len(ASP_LABEL[k]) * 7.2 + 22.0 for k in present) if leg_h else 0.0
73
+ W = max(gridW, legW) + pad * 2
74
+ H = gridW + pad * 2 + leg_h
75
+
76
+ def X(j): return pad + j * C
77
+ def Y(i): return pad + i * C
78
+
79
+ P = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.0f %.0f" '
80
+ 'width="%.0f" height="%.0f" style="cursor:default" role="img" '
81
+ 'aria-label="aspect grid">' % (W, H, W, H)]
82
+
83
+ # gradient DIRECTION (pass one): warm nexus pinned at the bottom-left corner,
84
+ # radiating perpendicular to the symbol diagonal toward the top-right. The axis
85
+ # runs along (1,-1), so iso-colour bands are straight lines PARALLEL to the symbol
86
+ # diagonal; the cool 100% stop-band lands exactly on the line through the symbols'
87
+ # top-right corners (x - y == one cell). End point (X((n+1)/2), Y((n-1)/2)) has
88
+ # x - y == C and BL->end is along (1,-1).
89
+ P.append('<defs><linearGradient id="%s" gradientUnits="userSpaceOnUse" '
90
+ 'x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f">'
91
+ '<stop offset="0%%" stop-color="%s"/><stop offset="100%%" stop-color="%s"/>'
92
+ '</linearGradient></defs>'
93
+ % (gid, X(0), Y(n), X((n + 1) / 2.0), Y((n - 1) / 2.0), gwarm, gcool))
94
+
95
+ # --- cell fills, glyphs & aspect symbols ---
96
+ for i, bi in enumerate(gorder):
97
+ for j in range(i + 1):
98
+ x, y = X(j), Y(i)
99
+ if j == i:
100
+ # hover the body's diagonal cell -> its name, sign & degree
101
+ P.append('<g><title>%s</title>' % _esc(body_label(bi, bodies.get(bi, {}))))
102
+ P.append('<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>'
103
+ % (x, y, C, C, diagbg))
104
+ P.append('<text x="%.1f" y="%.1f" fill="%s" font-family=\'%s\' font-size="%.1f" '
105
+ 'text-anchor="middle" dominant-baseline="central">%s︎</text></g>'
106
+ % (x + C / 2, y + C / 2, accent, SYM, C * 0.52,
107
+ _esc(PLANET_GLYPHS.get(bi) or
108
+ ("✦" if bodies.get(bi, {}).get("kind") == "star" else bi[:2]))))
109
+ else:
110
+ a = amap.get(frozenset((bi, gorder[j])))
111
+ if not a:
112
+ continue
113
+ col = pal[ASP_CAT.get(a.get("aspect"), "aSoft")]
114
+ asp = a.get("aspect")
115
+ tip = "%s %s %s · %.1f° orb" % (
116
+ _NICE.get(bi, bi), ASP_REL.get(asp, asp),
117
+ _NICE.get(gorder[j], gorder[j]), abs(float(a.get("orb", 0.0))))
118
+ P.append('<g><title>%s</title>'
119
+ '<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s" fill-opacity="0.13"/>'
120
+ '<text x="%.1f" y="%.1f" fill="%s" font-family=\'%s\' font-size="%.1f" '
121
+ 'font-weight="600" text-anchor="middle" dominant-baseline="central">%s︎</text></g>'
122
+ % (_esc(tip), x, y, C, C, col, x + C / 2, y + C / 2, col, SYM, C * 0.46,
123
+ _esc(ASP_SYM.get(a.get("aspect"), "·"))))
124
+
125
+ # --- interior gridlines: same gradient as the frame (pass two) ---
126
+ for i in range(1, n): # row separators (span columns 0..i)
127
+ P.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="url(#%s)" stroke-width="1.5" '
128
+ 'shape-rendering="crispEdges"/>' % (X(0), Y(i), X(i), Y(i), gid))
129
+ for j in range(1, n): # column separators (span rows j..n)
130
+ P.append('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="url(#%s)" stroke-width="1.5" '
131
+ 'shape-rendering="crispEdges"/>' % (X(j), Y(j), X(j), Y(n), gid))
132
+
133
+ # --- outer edge of the triangle (staircase frame, themed gradient stroke) ---
134
+ if n:
135
+ pts = [(X(0), Y(0)), (X(0), Y(n)), (X(n), Y(n))]
136
+ for i in range(n - 1, -1, -1):
137
+ pts.append((X(i + 1), Y(i)))
138
+ pts.append((X(i), Y(i)))
139
+ P.append('<polygon points="%s" fill="none" stroke="url(#%s)" stroke-width="4" '
140
+ 'stroke-linejoin="miter" shape-rendering="crispEdges"/>'
141
+ % (" ".join("%.1f,%.1f" % p for p in pts), gid))
142
+
143
+ # --- legend ---
144
+ if leg_h:
145
+ ly, lx = pad + gridW + 17.0, pad
146
+ for k in present:
147
+ P.append('<text x="%.1f" y="%.1f" fill="%s" font-family=\'%s\' font-size="14" '
148
+ 'dominant-baseline="central">%s︎</text>' % (lx, ly, pal[ASP_CAT[k]], SYM, _esc(ASP_SYM[k])))
149
+ lx += 20.0
150
+ P.append('<text x="%.1f" y="%.1f" fill="%s" font-family="system-ui,sans-serif" '
151
+ 'font-size="12.5" dominant-baseline="central">%s</text>' % (lx, ly, muted, ASP_LABEL[k]))
152
+ lx += len(ASP_LABEL[k]) * 7.2 + 22.0
153
+
154
+ P.append('</svg>')
155
+ return "\n".join(P)
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ wheel.py — render a natal chart as a self-contained SVG wheel. [SHIP]
4
+
5
+ Pure Python, zero dependencies. Takes the dict from chart.assemble() (or any dict
6
+ with the same shape) and returns an SVG string. Ascendant is placed at the left
7
+ (9 o'clock) with the zodiac increasing counter-clockwise — the standard chart
8
+ convention. Theme-aware (light/dark) via a CSS block.
9
+
10
+ Glyphs are standard Unicode astrological symbols (no font-licensing issue); if a
11
+ viewer's font lacks them, pass text_labels=True for 2-3 letter abbreviations.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+
18
+ SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍",
19
+ "♎", "♏", "♐", "♑", "♒", "♓"]
20
+ SIGN_ABBR = ["Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi"]
21
+
22
+ PLANET_GLYPHS = {
23
+ "Sun": "☉", "Moon": "☽", "Mercury": "☿", "Venus": "♀",
24
+ "Mars": "♂", "Jupiter": "♃", "Saturn": "♄", "Uranus": "♅",
25
+ "Neptune": "♆", "Pluto": "♇", "TrueNode": "☊", "MeanNode": "☊", "SouthNode": "☋",
26
+ "MeanLilith": "⚸", "OscuLilith": "⚸", "Chiron": "⚷",
27
+ "Ceres": "⚳", "Pallas": "⚴", "Juno": "⚵", "Vesta": "⚶",
28
+ "Eros": "♡", "Eris": "⯰", "AsteroidLilith": "☾", # no standard Unicode glyphs
29
+ "Hygeia": "⚕", "Astraea": "As", "Sedna": "⯲",
30
+ "PartOfFortune": "⊗", "Vertex": "Vx", "EastPoint": "Ep",
31
+ "Descendant": "Dc", "ImumCoeli": "Ic", "AriesPoint": "♈", "LibraPoint": "♎",
32
+ "CoAscendant": "Co",
33
+ # Uranian / hypothetical bodies — no standard Unicode glyphs, so 2-letter marks
34
+ "Cupido": "Cu", "Hades": "Ha", "Zeus": "Ze", "Kronos": "Kr", "Apollon": "Ap",
35
+ "Admetos": "Ad", "Vulcanus": "Vl", "Poseidon": "Po", "TransPluto": "TP",
36
+ "Vulcan": "Vu", "WhiteMoon": "Se", # Weston's Vulcan; Selena / White Moon
37
+ }
38
+ PLANET_ABBR = {k: (k[:2] if k not in ("Sun", "Moon") else k[:2]) for k in PLANET_GLYPHS}
39
+
40
+ _SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio",
41
+ "Sagittarius", "Capricorn", "Aquarius", "Pisces")
42
+ _NICE_NAMES = {"TrueNode": "North Node", "MeanNode": "North Node", "SouthNode": "South Node",
43
+ "MeanLilith": "Black Moon Lilith", "OscuLilith": "Black Moon Lilith",
44
+ "AsteroidLilith": "Asteroid Lilith", "PartOfFortune": "Part of Fortune",
45
+ "EastPoint": "East Point", "ImumCoeli": "Imum Coeli", "CoAscendant": "Co-Ascendant",
46
+ "AriesPoint": "Aries Point", "LibraPoint": "Libra Point",
47
+ "WhiteMoon": "White Moon (Selena)", "TransPluto": "Trans-Pluto"}
48
+
49
+
50
+ def body_label(name, body):
51
+ """Hover text for a body: 'Moon in Pisces at 5.00°' (+ ' ℞' when retrograde)."""
52
+ lon = float(body.get("lon", 0.0)) % 360.0
53
+ sign = body.get("sign") or _SIGNS[int(lon // 30) % 12]
54
+ deg = body.get("deg_in_sign")
55
+ if deg is None:
56
+ deg = lon % 30.0
57
+ s = "%s in %s at %.2f°" % (_NICE_NAMES.get(name, name), sign, float(deg))
58
+ return s + " ℞" if body.get("retro") else s
59
+
60
+
61
+ _ORDINALS = ("1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th",
62
+ "9th", "10th", "11th", "12th")
63
+
64
+
65
+ def _house_of_lon(lon, cusps):
66
+ for i in range(12):
67
+ span = (cusps[(i + 1) % 12] - cusps[i]) % 360.0
68
+ if span == 0.0 or (lon - cusps[i]) % 360.0 < span:
69
+ return i + 1
70
+ return 12
71
+
72
+
73
+ def sign_label(i, angles, cusps):
74
+ """Hover text for a zodiac sign: 'Virgo · 2nd house' (+ ' (Rising)' on the Asc sign)."""
75
+ name = _SIGNS[i % 12]
76
+ asc = (angles or {}).get("asc")
77
+ rising = asc is not None and int(asc // 30) % 12 == i % 12
78
+ if cusps and len(cusps) >= 12:
79
+ s = "%s · %s house" % (name, _ORDINALS[_house_of_lon(i * 30.0 + 15.0, cusps) - 1])
80
+ else:
81
+ s = name
82
+ return s + " (Rising)" if rising else s
83
+
84
+
85
+ def angle_label(key, lon):
86
+ """Hover text for the Asc/MC: 'Leo Ascendant at 12.34°'."""
87
+ lon %= 360.0
88
+ nm = {"asc": "Ascendant", "mc": "Midheaven"}.get(key, key)
89
+ return "%s %s at %.2f°" % (_SIGNS[int(lon // 30) % 12], nm, lon % 30.0)
90
+
91
+ # Aspect line styling (color, dash). Class names keyed for the CSS block.
92
+ ASPECT_STYLE = {
93
+ "conjunction": ("#8a8a8a", ""),
94
+ "opposition": ("#d7263d", ""),
95
+ "square": ("#d7263d", ""),
96
+ "trine": ("#1f7a8c", ""),
97
+ "sextile": ("#1f7a8c", "4 3"),
98
+ "quincunx": ("#3f8f3f", "2 3"),
99
+ "semisextile": ("#3f8f3f", "2 3"),
100
+ "semisquare": ("#c08a2e", "2 3"),
101
+ "sesquiquadrate": ("#c08a2e", "2 3"),
102
+ "quintile": ("#7a4fb0", "2 3"),
103
+ }
104
+
105
+ # Which aspects read as hard / soft / neutral (for per-theme aspect colouring).
106
+ ASPECT_CAT = {
107
+ "conjunction": "neutral",
108
+ "opposition": "hard", "square": "hard",
109
+ "semisquare": "hard", "sesquiquadrate": "hard",
110
+ "trine": "soft", "sextile": "soft",
111
+ "quincunx": "soft", "semisextile": "soft", "quintile": "soft",
112
+ }
113
+
114
+ # ---- colour palettes -> selectable "modes" ---------------------------------
115
+ # Each palette: bg (radial gradient in/out), ring (3 linear-gradient stops), then
116
+ # per-element colours. light/dark are functional; the rest are pastel "pretty"
117
+ # modes with a prism-like ring sweep. All are valid `theme=` values.
118
+ _L = dict(bg=("#ffffff", "#ffffff"), ring=("#b0b0b0", "#b0b0b0", "#b0b0b0"),
119
+ tick="#b0b0b0", sign="#3a3a3a", cusp="#7f9fd4", cuspA="#3f5f9c",
120
+ hdiv="#a3aab8", leader="#c3c7cf", housenum="#3a3a3a", anglelab="#1c1c1c",
121
+ pmark="#b6b6b6", planet="#111111", deg="#5f5f5f", degRx="#c0392b",
122
+ title="#222222", aHard="#d7263d", aSoft="#1f7a8c", aNeutral="#8a8a8a", halo=False)
123
+ _D = dict(bg=("#14161a", "#0f1114"), ring=("#3a3f47", "#3a3f47", "#3a3f47"),
124
+ tick="#3a3f47", sign="#c9cdd4", cusp="#6a84ad", cuspA="#a6c1ec",
125
+ hdiv="#5b6474", leader="#4c525e", housenum="#7a828c", anglelab="#d5d9df",
126
+ pmark="#454b54", planet="#f2f4f7", deg="#9aa1ab", degRx="#ff6f6f",
127
+ title="#e6e9ee", aHard="#e06c78", aSoft="#5fb0c0", aNeutral="#7a828c", halo=False)
128
+
129
+
130
+ def _pretty(bg, bg2, ring, ink, mid, line, lineA, accent, hard, soft):
131
+ return dict(bg=(bg, bg2), ring=ring, tick=line, sign=ink, cusp=line, cuspA=lineA,
132
+ hdiv=mid, leader=line, housenum=ink, anglelab=accent, pmark=mid,
133
+ planet=ink, deg=mid, degRx=hard, title=ink,
134
+ aHard=hard, aSoft=soft, aNeutral=mid, halo=True)
135
+
136
+
137
+ def _lerp_hex(c1, c2, t):
138
+ a = tuple(int(c1[i:i + 2], 16) for i in (1, 3, 5))
139
+ b = tuple(int(c2[i:i + 2], 16) for i in (1, 3, 5))
140
+ return "#%02x%02x%02x" % tuple(round(a[k] + (b[k] - a[k]) * t) for k in range(3))
141
+
142
+
143
+ def _conic(p, stops):
144
+ """Colour at fraction p in [0,1) sweeping the three ring stops around a loop."""
145
+ seg = (p % 1.0) * 3.0
146
+ i = int(seg) % 3
147
+ return _lerp_hex(stops[i], stops[(i + 1) % 3], seg - int(seg))
148
+
149
+
150
+ # Rotation applied to the halo sweep so the brightest/warmest ring stop (stops[2],
151
+ # naturally at screen 240° = ~7 o'clock) lands at the TOP of the wheel (screen 90°).
152
+ # colour = _conic(mid/360 + _HALO_TURN); stops[2] shows where the arg == 2/3, so
153
+ # 90/360 + _HALO_TURN == 2/3 -> _HALO_TURN = 2/3 - 1/4 = 150/360.
154
+ _HALO_TURN = 150.0 / 360.0
155
+
156
+ # Per-theme perceptual nudge of the halo nexus, in degrees CCW from the top (the
157
+ # nexus screen angle = 240 - 360*turn, and CCW = increasing screen angle, so the
158
+ # effective turn is (_HALO_TURN - deg/360)). Tuned by eye per palette; 0 = at top.
159
+ _HALO_ROT_DEG = {
160
+ "twilight": 30, "aurora": 30, "opal": 30, "seafoam": 30,
161
+ "meadow": 45, "dawn": 90, "blossom": 60, "infrared": 30,
162
+ # prism, ultraviolet (and the halo-less light/dark): 0
163
+ }
164
+
165
+
166
+ PALETTES = {
167
+ "light": _L, "dark": _D,
168
+ "aurora": _pretty("#f4f7fd", "#e8eef8", ("#8fb3e6", "#b9c9ee", "#d9b8e6"),
169
+ "#2a3550", "#5f6a86", "#a9bfe4", "#5f79b8", "#bf8f4a", "#cf7a8a", "#5f96b0"),
170
+ "seafoam": _pretty("#eef8f4", "#ddf0ea", ("#6fc2c9", "#a5dcd2", "#cfe6b0"),
171
+ "#22403c", "#567a74", "#9fd0cb", "#4f9a92", "#a98a45", "#d07a6e", "#3f9a90"),
172
+ "twilight": _pretty("#f2eef8", "#e6e0f0", ("#6a7fd0", "#9a8fd8", "#d29ac0"),
173
+ "#2e2846", "#6a6484", "#b3a6dd", "#6a5fb0", "#b98a4e", "#cf6a8a", "#6a7fc8"),
174
+ "dawn": _pretty("#fcf5ee", "#f7e9dc", ("#8fbce6", "#f0c69a", "#f0a48f"),
175
+ "#40342a", "#7a6a58", "#e3c3a3", "#c98f6f", "#bf7f3a", "#d0705a", "#5f96b8"),
176
+ "prism": _pretty("#f4f7fb", "#e8f0f8", ("#6aa0e0", "#7fd0c0", "#e8d98f"),
177
+ "#263242", "#5f6f80", "#a7c4e6", "#4f86c8", "#b98a3e", "#cf6f6a", "#3f9aa0"),
178
+ "opal": _pretty("#f6f5fb", "#edeef6", ("#a9c4ea", "#cdc2ec", "#ecd6b0"),
179
+ "#33324a", "#6a6a82", "#bfcbe8", "#7a86bf", "#b28a4a", "#cf7a90", "#6f96b8"),
180
+ "meadow": _pretty("#f2f7ee", "#e7f0de", ("#7fb0d8", "#a8cfa8", "#d8d68f"),
181
+ "#2c3a2a", "#5f6f56", "#aecbb0", "#6a9a72", "#a98a40", "#d0705a", "#4f9a86"),
182
+ "blossom": _pretty("#fbf3f6", "#f6e7ee", ("#8fb3e6", "#dab0d8", "#f0aab8"),
183
+ "#402a38", "#7a5f6e", "#e0bcd2", "#b06a92", "#bf7f5a", "#d06a86", "#6f96b8"),
184
+ # spectral "beyond visible" pair — dark grounds with a thermal / black-light glow
185
+ "infrared": _pretty("#1a0a0a", "#0d0404", ("#7a0d0d", "#e0431f", "#f2b705"),
186
+ "#f6e3d8", "#a06a52", "#5a2f28", "#ff7a3c", "#ff5a2e", "#ff3b2f", "#ffd25e"),
187
+ "ultraviolet": _pretty("#120a24", "#080413", ("#7a3cff", "#b03cff", "#3c9cff"),
188
+ "#ece4ff", "#7a68b0", "#3a2a5e", "#a06cff", "#b57bff", "#ff5ad0", "#6ab8ff"),
189
+ }
190
+
191
+
192
+ def _defs(pal):
193
+ b0, b1 = pal["bg"]
194
+ r0, r1, r2 = pal["ring"]
195
+ return ('<defs>'
196
+ '<radialGradient id="bggrad" cx="50%" cy="40%" r="82%">'
197
+ '<stop id="bs0" offset="0%" stop-color="' + b0 + '"/>'
198
+ '<stop id="bs1" offset="100%" stop-color="' + b1 + '"/>'
199
+ '</radialGradient>'
200
+ '<linearGradient id="ringgrad" x1="0.05" y1="0.05" x2="0.95" y2="0.95">'
201
+ '<stop id="rs0" offset="0%" stop-color="' + r0 + '"/>'
202
+ '<stop id="rs1" offset="50%" stop-color="' + r1 + '"/>'
203
+ '<stop id="rs2" offset="100%" stop-color="' + r2 + '"/>'
204
+ '</linearGradient></defs>')
205
+
206
+
207
+ def _style(pal):
208
+ sym = '"Segoe UI Symbol","Noto Sans Symbols2","Apple Symbols",system-ui,sans-serif'
209
+ rules = [
210
+ (".ring", "fill:none;stroke:url(#ringgrad);stroke-width:3.4"),
211
+ (".tick", "stroke:%s;stroke-width:3.4" % pal["tick"]),
212
+ (".sign", "fill:%s;font:600 28px %s" % (pal["sign"], sym)),
213
+ (".cusp", "stroke:%s;stroke-width:2" % pal["cusp"]),
214
+ (".cusp-angle", "stroke:%s;stroke-width:2.4" % pal["cuspA"]),
215
+ (".hdiv", "stroke:%s;stroke-width:1.3" % pal["hdiv"]),
216
+ (".leader", "stroke:%s;stroke-width:2;stroke-dasharray:1 5;stroke-linecap:round" % pal["leader"]),
217
+ (".housenum", "fill:%s;font:600 19px system-ui,sans-serif" % pal["housenum"]),
218
+ (".anglelab", "fill:%s;font:800 28px system-ui,sans-serif" % pal["anglelab"]),
219
+ (".ac-sm", "font-size:0.5em"),
220
+ (".pmark", "stroke:%s;stroke-width:1.6" % pal["pmark"]),
221
+ (".planet", "fill:%s;font:600 33px %s" % (pal["planet"], sym)),
222
+ (".deg", "fill:%s;font:600 18px system-ui,sans-serif" % pal["deg"]),
223
+ (".deg-rx", "fill:%s" % pal["degRx"]),
224
+ (".aspect", "stroke-width:1.1;fill:none;opacity:.85"),
225
+ (".title", "fill:%s;font:600 15px system-ui,sans-serif" % pal["title"]),
226
+ ]
227
+ return "<style>" + "".join("%s{%s}" % (s, p) for s, p in rules) + "</style>"
228
+
229
+
230
+ def _dark_media():
231
+ d = _D
232
+ return ('<style>@media (prefers-color-scheme: dark){'
233
+ '#bs0{stop-color:' + d["bg"][0] + '}#bs1{stop-color:' + d["bg"][1] + '}'
234
+ '#rs0,#rs1,#rs2{stop-color:' + d["ring"][0] + '}'
235
+ '.tick{stroke:' + d["tick"] + '}.sign{fill:' + d["sign"] + '}'
236
+ '.cusp{stroke:' + d["cusp"] + '}.cusp-angle{stroke:' + d["cuspA"] + '}'
237
+ '.hdiv{stroke:' + d["hdiv"] + '}.leader{stroke:' + d["leader"] + '}'
238
+ '.housenum{fill:' + d["housenum"] + '}.anglelab{fill:' + d["anglelab"] + '}'
239
+ '.pmark{stroke:' + d["pmark"] + '}.planet{fill:' + d["planet"] + '}'
240
+ '.deg{fill:' + d["deg"] + '}.deg-rx{fill:' + d["degRx"] + '}'
241
+ '.title{fill:' + d["title"] + '}}</style>')
242
+
243
+
244
+ def _theme_markup(theme):
245
+ """Return the <defs> gradients + <style> for a theme (auto = light + dark media)."""
246
+ if theme == "auto":
247
+ return _defs(_L) + _style(_L) + _dark_media()
248
+ pal = PALETTES.get(theme, _L)
249
+ return _defs(pal) + _style(pal)
250
+
251
+
252
+ def _esc(s: str) -> str:
253
+ return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
254
+
255
+
256
+ def render_svg(chart: dict, size: int = 760, theme: str = "auto",
257
+ text_labels: bool = False, title: str | None = None) -> str:
258
+ cx = cy = size / 2.0
259
+ r_out = size * 0.45 # outer edge (leaves a margin for AC/MC outside)
260
+ r_zod_in = r_out * 0.862 # inner edge of the zodiac band (signs live here)
261
+ r_house = r_zod_in # house-cusp lines reach the zodiac inner edge
262
+ r_tick_in = r_zod_in * 0.905 # inner end of the planet pointer ticks
263
+ r_glyph = r_zod_in * 0.845 # planet-glyph ring (pushed out for more room)
264
+ r_hnum_out = r_zod_in * 0.43 # house-ring outer = former aspect-circle radius
265
+ r_hub = r_zod_in * 0.33 # aspect circle == house-ring inner edge (they meet)
266
+ r_hnum = r_zod_in * 0.38 # house numbers, centred in the (wider) ring band
267
+ glyphs = None if not text_labels else True
268
+ apal = _L if theme == "auto" else PALETTES.get(theme, _L) # aspect colours
269
+
270
+ angles = chart.get("angles") or {}
271
+ asc = angles.get("asc", 0.0) # unknown-time -> 0 Aries at left
272
+ cusps = chart.get("cusps")
273
+
274
+ def pol(r, lon):
275
+ """Ecliptic longitude -> (x, y). Asc at left, zodiac CCW."""
276
+ a = math.radians(180.0 + (lon - asc))
277
+ return cx + r * math.cos(a), cy - r * math.sin(a)
278
+
279
+ def sp(r, deg):
280
+ """Screen-space polar (independent of the chart's rotation) for halo bands."""
281
+ a = math.radians(deg)
282
+ return cx + r * math.cos(a), cy - r * math.sin(a)
283
+
284
+ P = [] # svg fragments
285
+ P.append(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" '
286
+ f'width="{size}" height="{size}" style="cursor:default" role="img" '
287
+ f'aria-label="{_esc(title or "natal chart")}">')
288
+ P.append(_theme_markup(theme))
289
+ P.append(f'<rect x="0" y="0" width="{size}" height="{size}" class="bg" fill="url(#bggrad)"/>')
290
+
291
+ # --- rings / prism-halo bands ---
292
+ if apal.get("halo"):
293
+ # fill the zodiac band and the house-number band with an angular sweep of
294
+ # the ring stops (a true "halo" around the wheel), then outline the edges.
295
+ stops = apal["ring"]
296
+ turn = _HALO_TURN - _HALO_ROT_DEG.get(theme, 0) / 360.0 # per-theme CCW nudge
297
+ N = 120
298
+ for rin, rout in ((r_zod_in, r_out), (r_hub, r_hnum_out)):
299
+ for sgi in range(N):
300
+ a0, a1 = 360.0 * sgi / N, 360.0 * (sgi + 1) / N
301
+ col = _conic((a0 + a1) / 720.0 + turn, stops)
302
+ x1, y1 = sp(rout, a0)
303
+ x2, y2 = sp(rout, a1)
304
+ x3, y3 = sp(rin, a1)
305
+ x4, y4 = sp(rin, a0)
306
+ P.append(f'<polygon points="{x1:.1f},{y1:.1f} {x2:.1f},{y2:.1f} '
307
+ f'{x3:.1f},{y3:.1f} {x4:.1f},{y4:.1f}" fill="{col}"/>')
308
+ for r in (r_out, r_zod_in, r_hnum_out, r_hub):
309
+ P.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" '
310
+ f'fill="none" stroke="rgba(38,44,60,.28)" stroke-width="1.4"/>')
311
+ else:
312
+ for r in (r_out, r_zod_in, r_hnum_out, r_hub):
313
+ P.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" class="ring"/>')
314
+
315
+ # --- zodiac: 12 sectors (fixed to longitude) ---
316
+ for i in range(12):
317
+ lon0 = i * 30.0
318
+ x1, y1 = pol(r_zod_in, lon0)
319
+ x2, y2 = pol(r_out, lon0)
320
+ P.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" class="tick"/>')
321
+ gx, gy = pol((r_zod_in + r_out) / 2.0, lon0 + 15.0)
322
+ label = SIGN_ABBR[i] if glyphs else SIGN_GLYPHS[i] + "︎" # text, not emoji
323
+ P.append(f'<text x="{gx:.1f}" y="{gy:.1f}" class="sign" '
324
+ f'dominant-baseline="central" text-anchor="middle">'
325
+ f'<title>{_esc(sign_label(i, angles, cusps))}</title>{label}</text>')
326
+
327
+ # --- houses (cusp lines here; numbers collected and drawn last, on top) ---
328
+ house_labels = []
329
+ if cusps and len(cusps) >= 12:
330
+ # emphasise the four angle cusps only when they ARE the angles (quadrant
331
+ # systems like Placidus); in whole-sign/equal the cusps are sign boundaries,
332
+ # so every division stays uniform.
333
+ _asc, _mc = angles.get("asc"), angles.get("mc")
334
+ quad = (_asc is not None and _mc is not None
335
+ and abs((cusps[0] - _asc + 180.0) % 360.0 - 180.0) < 0.5
336
+ and abs((cusps[9] - _mc + 180.0) % 360.0 - 180.0) < 0.5)
337
+ for i, c in enumerate(cusps):
338
+ cls = "cusp-angle" if (quad and i in (0, 3, 6, 9)) else "cusp"
339
+ # solid divider between house numbers, within the number ring
340
+ hx1, hy1 = pol(r_hub, c)
341
+ hx2, hy2 = pol(r_hnum_out, c)
342
+ P.append(f'<line x1="{hx1:.1f}" y1="{hy1:.1f}" x2="{hx2:.1f}" y2="{hy2:.1f}" class="hdiv"/>')
343
+ # dotted cusp continues from the number ring out to the zodiac
344
+ dx1, dy1 = pol(r_hnum_out, c)
345
+ dx2, dy2 = pol(r_house, c)
346
+ P.append(f'<line x1="{dx1:.1f}" y1="{dy1:.1f}" x2="{dx2:.1f}" y2="{dy2:.1f}" class="{cls}"/>')
347
+ mid = c + (((cusps[(i + 1) % 12] - c) % 360.0) / 2.0)
348
+ nx, ny = pol(r_hnum, mid)
349
+ house_labels.append(f'<text x="{nx:.1f}" y="{ny:.1f}" class="housenum" '
350
+ f'dominant-baseline="central" text-anchor="middle">{i + 1}</text>')
351
+ # Asc / MC labels, OUTSIDE the ring (so the halo bands don't clobber them);
352
+ # the trailing "C" is half-height, baseline-aligned (not descending).
353
+ r_ang = r_out + size * 0.03
354
+ for key, first in (("asc", "A"), ("mc", "M")):
355
+ if key in angles:
356
+ lx, ly = pol(r_ang, angles[key])
357
+ P.append(f'<text x="{lx:.1f}" y="{ly + size*0.008:.1f}" class="anglelab" '
358
+ f'text-anchor="middle"><title>{_esc(angle_label(key, angles[key]))}</title>'
359
+ f'{first}<tspan class="ac-sm">C</tspan></text>')
360
+
361
+ # --- planets (with simple angular de-collision on the display ring) ---
362
+ bodies = chart.get("bodies") or {}
363
+ # Combined de-collision: a gentle angular (circumferential) spread PLUS radial
364
+ # tiers where bodies pile up. Glyphs stay near their true longitude; a dotted
365
+ # leader ties any displaced glyph back to its true mark, so nothing is lost.
366
+ ANG = 6.0 # angular min-gap (deg) — gentle circumferential fan
367
+ RAD_TH = 12.0 # within this angular gap, stack radially instead
368
+ STEP = r_zod_in * 0.173 # radial gap between tiers
369
+ DOFF = r_zod_in * 0.086 # degree label offset below its glyph
370
+ placed = _spread([(n, bodies[n]["lon"]) for n in bodies], min_gap=ANG)
371
+ order = sorted(range(len(placed)), key=lambda k: placed[k][2])
372
+ last: list[float] = []
373
+ tier = [0] * len(placed)
374
+ for k in order:
375
+ d = placed[k][2]
376
+ t = 0
377
+ while t < len(last) and (d - last[t]) % 360.0 < RAD_TH:
378
+ t += 1
379
+ if t == len(last):
380
+ last.append(d)
381
+ else:
382
+ last[t] = d
383
+ tier[k] = t
384
+ hub_pts = {}
385
+ for k, (name, lon, disp) in enumerate(placed):
386
+ rg = r_glyph - tier[k] * STEP
387
+ # true-position mark: a short spoke just inside the zodiac
388
+ tx1, ty1 = pol(r_tick_in, lon)
389
+ tx2, ty2 = pol(r_house, lon)
390
+ P.append(f'<line x1="{tx1:.1f}" y1="{ty1:.1f}" x2="{tx2:.1f}" y2="{ty2:.1f}" class="pmark"/>')
391
+ # displaced glyph: dotted leader back to its true mark (keeps the link)
392
+ drift = abs((disp - lon + 180.0) % 360.0 - 180.0)
393
+ if tier[k] > 0 or drift > 2.5:
394
+ gx0, gy0 = pol(rg + r_zod_in * 0.045, disp)
395
+ P.append(f'<line x1="{gx0:.1f}" y1="{gy0:.1f}" x2="{tx1:.1f}" y2="{ty1:.1f}" class="leader"/>')
396
+ gx, gy = pol(rg, disp)
397
+ if glyphs:
398
+ g = PLANET_ABBR.get(name, name[:2])
399
+ else: # fixed stars share one ✦ marker; the name identifies them in the key
400
+ base = PLANET_GLYPHS.get(name) or ("✦" if bodies[name].get("kind") == "star" else name[:2])
401
+ g = base + "︎" # text, not emoji
402
+ retro = bodies[name].get("retro")
403
+ lbl = _esc(body_label(name, bodies[name])) # hover: name in sign at deg
404
+ P.append(f'<text x="{gx:.1f}" y="{gy:.1f}" class="planet" '
405
+ f'dominant-baseline="central" text-anchor="middle">'
406
+ f'<title>{lbl}</title>{_esc(g)}</text>')
407
+ dxp, dyp = pol(rg - DOFF, disp)
408
+ deg = f'{int(lon % 30)}°' + ("℞" if retro else "")
409
+ dcls = "deg deg-rx" if retro else "deg"
410
+ P.append(f'<text x="{dxp:.1f}" y="{dyp:.1f}" class="{dcls}" '
411
+ f'dominant-baseline="central" text-anchor="middle">'
412
+ f'<title>{lbl}</title>{deg}</text>')
413
+ hub_pts[name] = pol(r_hub, lon)
414
+
415
+ # --- aspects (lines across the hub) ---
416
+ for asp in chart.get("aspects") or []:
417
+ a, b = asp.get("a"), asp.get("b")
418
+ if a in hub_pts and b in hub_pts:
419
+ cat = ASPECT_CAT.get(asp.get("aspect"), "soft")
420
+ color = apal["a" + cat.capitalize()]
421
+ _, dash = ASPECT_STYLE.get(asp.get("aspect"), ("", "2 3"))
422
+ (x1, y1), (x2, y2) = hub_pts[a], hub_pts[b]
423
+ da = f' stroke-dasharray="{dash}"' if dash else ""
424
+ P.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
425
+ f'stroke="{color}" class="aspect"{da}/>')
426
+
427
+ # house numbers last, so they sit in front of the cusp lines and ticks
428
+ P.extend(house_labels)
429
+
430
+ if title:
431
+ P.append(f'<text x="{cx:.1f}" y="{size*0.04:.1f}" class="title" '
432
+ f'text-anchor="middle">{_esc(title)}</text>')
433
+ P.append('</svg>')
434
+ return "\n".join(P)
435
+
436
+
437
+ def _spread(items, min_gap=7.0):
438
+ """De-collide glyph display angles while preserving zodiacal order. Cut the
439
+ circle at its widest gap so a cluster can open into free space, enforce the
440
+ minimum gap in one forward sweep (no crossovers), then recentre on the true
441
+ centroid to keep drift minimal and symmetric. Returns (name, true_lon, disp)."""
442
+ items = sorted(items, key=lambda t: t[1])
443
+ n = len(items)
444
+ if n < 2:
445
+ return [(nm, lo, lo) for nm, lo in items]
446
+ lons = [lo for _, lo in items]
447
+ gaps = [(lons[(i + 1) % n] - lons[i]) % 360.0 for i in range(n)]
448
+ s = max(range(n), key=lambda i: gaps[i]) # widest gap -> seam
449
+ order = [(s + 1 + k) % n for k in range(n)] # chain order from the seam
450
+ u, prev = [], lons[order[0]] # unwrap along the chain
451
+ for idx in order:
452
+ v = lons[idx]
453
+ while v < prev - 1e-9:
454
+ v += 360.0
455
+ u.append(v)
456
+ prev = v
457
+ tgt = u[:]
458
+ for k in range(1, n): # forward sweep: enforce gap
459
+ if u[k] < u[k - 1] + min_gap:
460
+ u[k] = u[k - 1] + min_gap
461
+ shift = sum(tgt[k] - u[k] for k in range(n)) / n # recentre on true centroid
462
+ disp = [0.0] * n
463
+ for k, idx in enumerate(order):
464
+ disp[idx] = (u[k] + shift) % 360.0
465
+ return [(items[i][0], items[i][1], disp[i]) for i in range(n)]
466
+
467
+
468
+ if __name__ == "__main__":
469
+ from openephem import houses
470
+ # Real analytic houses (no skyfield) + a few mock planets, to a file.
471
+ h = houses.houses_from_jd(2448027.270833, 40.7128, -74.0060, "Placidus")
472
+ demo = {
473
+ "angles": {"asc": h.asc, "mc": h.mc, "vertex": h.vertex, "east_point": h.east_point},
474
+ "cusps": h.cusps,
475
+ "bodies": {
476
+ "Sun": {"lon": 54.6, "retro": False}, "Moon": {"lon": 300.2, "retro": False},
477
+ "Mercury": {"lon": 47.1, "retro": True}, "Venus": {"lon": 78.0, "retro": False},
478
+ "Mars": {"lon": 300.9, "retro": False}, "Saturn": {"lon": 293.4, "retro": False},
479
+ },
480
+ "aspects": [
481
+ {"a": "Sun", "b": "Mercury", "aspect": "conjunction", "orb": 7.5},
482
+ {"a": "Moon", "b": "Saturn", "aspect": "conjunction", "orb": 6.8},
483
+ {"a": "Sun", "b": "Moon", "aspect": "trine", "orb": -5.6},
484
+ ],
485
+ }
486
+ svg = render_svg(demo, title="Demo Chart")
487
+ with open("demo_chart.svg", "w", encoding="utf-8") as fh:
488
+ fh.write(svg)
489
+ print(f"wrote demo_chart.svg ({len(svg)} bytes); asc={h.asc:.2f} mc={h.mc:.2f}")