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.
- ephemvis-0.1.0/.gitignore +20 -0
- ephemvis-0.1.0/CHANGELOG.md +29 -0
- ephemvis-0.1.0/LICENSE +33 -0
- ephemvis-0.1.0/PKG-INFO +91 -0
- ephemvis-0.1.0/README.md +62 -0
- ephemvis-0.1.0/pyproject.toml +66 -0
- ephemvis-0.1.0/src/ephemvis/__init__.py +28 -0
- ephemvis-0.1.0/src/ephemvis/aspectgrid.py +155 -0
- ephemvis-0.1.0/src/ephemvis/wheel.py +489 -0
|
@@ -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.
|
ephemvis-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/ephemvis/)
|
|
34
|
+
[](https://pypi.org/project/ephemvis/)
|
|
35
|
+
[](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
|
ephemvis-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ephemvis
|
|
2
|
+
|
|
3
|
+
[](https://github.com/NoahChristian/ephemvis/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/ephemvis/)
|
|
5
|
+
[](https://pypi.org/project/ephemvis/)
|
|
6
|
+
[](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("&", "&").replace("<", "<").replace(">", ">"))
|
|
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}")
|