"""
Zodiac Color Palettes (stellium.visualization.palettes)
Defines color schemes for the zodiac wheel visualization, aspect lines,
planet glyphs, and color utilities for adaptive theming.
"""
import colorsys
from enum import StrEnum
from functools import lru_cache
[docs]
class ZodiacPalette(StrEnum):
"""Available color palettes for the zodiac wheel."""
# Base palettes
GREY = "grey"
RAINBOW = "rainbow"
ELEMENTAL = "elemental"
CARDINALITY = "cardinality"
# Theme-coordinated rainbow variants
RAINBOW_DARK = "rainbow_dark"
RAINBOW_MIDNIGHT = "rainbow_midnight"
RAINBOW_NEON = "rainbow_neon"
RAINBOW_SEPIA = "rainbow_sepia"
RAINBOW_CELESTIAL = "rainbow_celestial"
# Theme-coordinated elemental variants
ELEMENTAL_DARK = "elemental_dark"
ELEMENTAL_MIDNIGHT = "elemental_midnight"
ELEMENTAL_NEON = "elemental_neon"
ELEMENTAL_SEPIA = "elemental_sepia"
# Data science palettes
VIRIDIS = "viridis"
PLASMA = "plasma"
INFERNO = "inferno"
MAGMA = "magma"
CIVIDIS = "cividis"
TURBO = "turbo"
COOLWARM = "coolwarm"
SPECTRAL = "spectral"
# Zodiac sign properties for palette mapping
SIGN_ELEMENTS = {
0: "fire", # Aries
1: "earth", # Taurus
2: "air", # Gemini
3: "water", # Cancer
4: "fire", # Leo
5: "earth", # Virgo
6: "air", # Libra
7: "water", # Scorpio
8: "fire", # Sagittarius
9: "earth", # Capricorn
10: "air", # Aquarius
11: "water", # Pisces
}
SIGN_MODALITIES = {
0: "cardinal", # Aries
1: "fixed", # Taurus
2: "mutable", # Gemini
3: "cardinal", # Cancer
4: "fixed", # Leo
5: "mutable", # Virgo
6: "cardinal", # Libra
7: "fixed", # Scorpio
8: "mutable", # Sagittarius
9: "cardinal", # Capricorn
10: "fixed", # Aquarius
11: "mutable", # Pisces
}
[docs]
@lru_cache(maxsize=128)
def get_palette_colors(palette: ZodiacPalette | str) -> list[str]:
"""
Get the color list for a zodiac wheel palette.
Returns a list of 12 colors (one per sign, starting with Aries).
Results are cached in memory for performance.
Special case: If palette is a string starting with "single_color:",
extracts the hex color and returns 12 copies of it for a monochrome wheel.
Args:
palette: The palette to use (ZodiacPalette enum or "single_color:#RRGGBB" string)
Returns:
List of 12 hex color strings
"""
# Handle dynamic single-color palettes
if isinstance(palette, str) and palette.startswith("single_color:"):
# Extract hex color from "single_color:#RRGGBB"
color = palette.split(":", 1)[1]
return [color] * 12
# Convert string to enum if needed (for backwards compatibility)
if isinstance(palette, str):
palette = ZodiacPalette(palette)
if palette == ZodiacPalette.GREY:
# All signs same color (classic grey)
return ["#EEEEEE"] * 12
elif palette == ZodiacPalette.RAINBOW:
# Tasteful rainbow: soft, desaturated colors progressing through hue wheel
# Starting with Aries (red) and progressing through the spectrum
return [
"#E8B4B8", # Aries - soft red
"#E8C4B8", # Taurus - soft orange
"#E8D8B8", # Gemini - soft yellow-orange
"#E8E8B8", # Cancer - soft yellow
"#D8E8B8", # Leo - soft yellow-green
"#C4E8B8", # Virgo - soft green
"#B8E8C4", # Libra - soft cyan-green
"#B8E8D8", # Scorpio - soft cyan
"#B8D8E8", # Sagittarius - soft blue
"#B8C4E8", # Capricorn - soft indigo
"#C4B8E8", # Aquarius - soft violet
"#D8B8E8", # Pisces - soft magenta
]
elif palette == ZodiacPalette.ELEMENTAL:
# 4-color elemental palette
element_colors = {
"fire": "#F4D4D4", # Soft warm red
"earth": "#D4E4D4", # Soft green
"air": "#D4E4F4", # Soft blue
"water": "#E4D4F4", # Soft purple
}
return [element_colors[SIGN_ELEMENTS[i]] for i in range(12)]
elif palette == ZodiacPalette.CARDINALITY:
# 3-color cardinality/modality palette
modality_colors = {
"cardinal": "#F4E4D4", # Soft peach (initiating)
"fixed": "#D4E4E4", # Soft teal (sustaining)
"mutable": "#E4D4E4", # Soft lavender (adapting)
}
return [modality_colors[SIGN_MODALITIES[i]] for i in range(12)]
# ========================================================================
# Theme-coordinated rainbow variants
# ========================================================================
elif palette == ZodiacPalette.RAINBOW_DARK:
# Dark theme: Muted, darker rainbow colors
return [
"#B88B8F", # Aries - muted red
"#B89B8F", # Taurus - muted orange
"#B8AB8F", # Gemini - muted yellow-orange
"#B8B88F", # Cancer - muted yellow
"#ABB88F", # Leo - muted yellow-green
"#9BB88F", # Virgo - muted green
"#8FB89B", # Libra - muted cyan-green
"#8FB8AB", # Scorpio - muted cyan
"#8FABB8", # Sagittarius - muted blue
"#8F9BB8", # Capricorn - muted indigo
"#9B8FB8", # Aquarius - muted violet
"#AB8FB8", # Pisces - muted magenta
]
elif palette == ZodiacPalette.RAINBOW_MIDNIGHT:
# Midnight theme: Cool, deep blues and purples
return [
"#4A5A7C", # Aries - deep blue-grey
"#3A6A8C", # Taurus - deep cyan-blue
"#3A7A9C", # Gemini - deep cyan
"#3A8AAC", # Cancer - deep sky blue
"#3A8A9C", # Leo - deep teal
"#3A9A8C", # Virgo - deep blue-green
"#3A9A7C", # Libra - deep sea green
"#3A8A6C", # Scorpio - deep forest green
"#4A6A7C", # Sagittarius - deep blue
"#5A5A8C", # Capricorn - deep indigo
"#6A4A8C", # Aquarius - deep purple
"#7A3A8C", # Pisces - deep magenta
]
elif palette == ZodiacPalette.RAINBOW_NEON:
# Neon theme: Super bright, saturated neon colors
return [
"#FF00AA", # Aries - hot pink
"#FF3300", # Taurus - neon orange-red
"#FF6600", # Gemini - neon orange
"#FFFF00", # Cancer - electric yellow
"#AAFF00", # Leo - neon lime
"#00FF00", # Virgo - electric green
"#00FFAA", # Libra - neon cyan-green
"#00FFFF", # Scorpio - electric cyan
"#0088FF", # Sagittarius - neon blue
"#0000FF", # Capricorn - electric blue
"#AA00FF", # Aquarius - neon violet
"#FF00FF", # Pisces - electric magenta
]
elif palette == ZodiacPalette.RAINBOW_SEPIA:
# Sepia theme: Warm browns, oranges, and earth tones
return [
"#C4A090", # Aries - terracotta
"#C4AA90", # Taurus - warm tan
"#C4B490", # Gemini - sandy brown
"#C4BE90", # Cancer - wheat
"#B4C490", # Leo - sage
"#AAC490", # Virgo - olive
"#90C4AA", # Libra - sea foam brown
"#90C4B4", # Scorpio - sage blue
"#90B4C4", # Sagittarius - dusty blue
"#90AAC4", # Capricorn - slate blue
"#A090C4", # Aquarius - dusty purple
"#AA90C4", # Pisces - mauve
]
elif palette == ZodiacPalette.RAINBOW_CELESTIAL:
# Celestial theme: Deep cosmic purples, blues, and golds
return [
"#9B4FA3", # Aries - cosmic purple
"#8B5FAF", # Taurus - deep lavender
"#7B6FAF", # Gemini - periwinkle
"#6B7FAF", # Cancer - cosmic blue
"#5B8FAF", # Leo - stellar blue
"#4B9FAF", # Virgo - galaxy cyan
"#4BAFAF", # Libra - nebula teal
"#4BAFAF", # Scorpio - deep teal
"#5B9FAF", # Sagittarius - space blue
"#6B8FAF", # Capricorn - cosmic indigo
"#7B7FAF", # Aquarius - deep violet
"#8B6FAF", # Pisces - stellar purple
]
# ========================================================================
# Theme-coordinated elemental variants
# ========================================================================
elif palette == ZodiacPalette.ELEMENTAL_DARK:
# Dark theme: Darker, muted elemental colors
element_colors = {
"fire": "#B88080", # Darker warm red
"earth": "#80A880", # Darker green
"air": "#8080B8", # Darker blue
"water": "#A880B8", # Darker purple
}
return [element_colors[SIGN_ELEMENTS[i]] for i in range(12)]
elif palette == ZodiacPalette.ELEMENTAL_MIDNIGHT:
# Midnight theme: Cool-toned elements
element_colors = {
"fire": "#5A6A8C", # Cool blue-grey (fire as stellium)
"earth": "#4A7A7C", # Deep teal (earth as ocean)
"air": "#6A7AAC", # Deep sky blue
"water": "#5A5A8C", # Deep indigo
}
return [element_colors[SIGN_ELEMENTS[i]] for i in range(12)]
elif palette == ZodiacPalette.ELEMENTAL_NEON:
# Neon theme: Electric bright elements
element_colors = {
"fire": "#FF0066", # Electric magenta
"earth": "#00FF66", # Neon green
"air": "#00CCFF", # Electric cyan
"water": "#CC00FF", # Neon purple
}
return [element_colors[SIGN_ELEMENTS[i]] for i in range(12)]
elif palette == ZodiacPalette.ELEMENTAL_SEPIA:
# Sepia theme: Warm-toned elements
element_colors = {
"fire": "#C49080", # Terracotta
"earth": "#A0B490", # Olive
"air": "#90A8C4", # Dusty blue
"water": "#A490B4", # Dusty purple
}
return [element_colors[SIGN_ELEMENTS[i]] for i in range(12)]
# ========================================================================
# Data Science Palettes (12-color samples from matplotlib colormaps)
# ========================================================================
elif palette == ZodiacPalette.VIRIDIS:
# Viridis: perceptually uniform, colorblind-friendly (purple → green → yellow)
return [
"#440154",
"#482475",
"#414487",
"#355F8D",
"#2A788E",
"#21918C",
"#22A884",
"#42BE71",
"#7AD151",
"#BBDF27",
"#FDE724",
"#FDE724",
]
elif palette == ZodiacPalette.PLASMA:
# Plasma: vibrant (dark blue → purple → orange → yellow)
return [
"#0D0887",
"#41049D",
"#6A00A8",
"#8F0DA4",
"#B12A90",
"#CC4778",
"#E16462",
"#F1844B",
"#FCA636",
"#FCCE25",
"#F0F921",
"#F0F921",
]
elif palette == ZodiacPalette.INFERNO:
# Inferno: dramatic (black → red → orange → yellow → white)
return [
"#000004",
"#1B0C41",
"#4A0C6B",
"#781C6D",
"#A52C60",
"#CF4446",
"#ED6925",
"#FB9A06",
"#F7D03C",
"#FCFFA4",
"#FCFFA4",
"#FCFFA4",
]
elif palette == ZodiacPalette.MAGMA:
# Magma: subtle (black → purple → pink → yellow → white)
return [
"#000004",
"#0B0924",
"#231151",
"#410F75",
"#5F187F",
"#7B2382",
"#982D80",
"#B73779",
"#D3436E",
"#EB5760",
"#F8765C",
"#FCFDBF",
]
elif palette == ZodiacPalette.CIVIDIS:
# Cividis: optimized for color vision deficiency (blue → yellow)
return [
"#00204C",
"#00306E",
"#00447A",
"#25567B",
"#4E6B7C",
"#73807D",
"#9B9680",
"#C5AC83",
"#E5C482",
"#FDDC7D",
"#FEE883",
"#FFEA46",
]
elif palette == ZodiacPalette.TURBO:
# Turbo: Google's improved rainbow (blue → cyan → green → yellow → red)
return [
"#30123B",
"#4662D7",
"#1FAAD2",
"#1AE4B6",
"#72FE5E",
"#C8EF34",
"#FABA39",
"#F66B19",
"#CA2A04",
"#7A0403",
"#7A0403",
"#7A0403",
]
elif palette == ZodiacPalette.COOLWARM:
# Coolwarm: diverging (blue → white → red)
return [
"#3B4CC0",
"#5E6EC5",
"#7F91CB",
"#A1B4D0",
"#C3D7D6",
"#E5E5E5",
"#F1D4D0",
"#F3B6AF",
"#EC8C88",
"#DD5C5C",
"#C73333",
"#B40426",
]
elif palette == ZodiacPalette.SPECTRAL:
# Spectral: diverging (red → yellow → green → blue → purple)
return [
"#9E0142",
"#D53E4F",
"#F46D43",
"#FDAE61",
"#FEE08B",
"#FFFFBF",
"#E6F598",
"#ABDDA4",
"#66C2A5",
"#3288BD",
"#5E4FA2",
"#5E4FA2",
]
else:
# Fallback to grey
return ["#EEEEEE"] * 12
[docs]
def get_palette_description(palette: ZodiacPalette) -> str:
"""
Get a human-readable description of a palette.
Args:
palette: The palette to describe
Returns:
Description string
"""
descriptions = {
# Base palettes
ZodiacPalette.GREY: "Classic grey wheel (no color)",
ZodiacPalette.RAINBOW: "Rainbow spectrum (12 soft colors)",
ZodiacPalette.ELEMENTAL: "4-color elemental (Fire/Earth/Air/Water)",
ZodiacPalette.CARDINALITY: "3-color modality (Cardinal/Fixed/Mutable)",
# Rainbow variants
ZodiacPalette.RAINBOW_DARK: "Dark rainbow (muted, darker spectrum)",
ZodiacPalette.RAINBOW_MIDNIGHT: "Midnight rainbow (cool blues and purples)",
ZodiacPalette.RAINBOW_NEON: "Neon rainbow (super bright electric colors)",
ZodiacPalette.RAINBOW_SEPIA: "Sepia rainbow (warm browns and earth tones)",
ZodiacPalette.RAINBOW_CELESTIAL: "Celestial rainbow (cosmic purples and blues)",
# Elemental variants
ZodiacPalette.ELEMENTAL_DARK: "Dark elemental (muted element colors)",
ZodiacPalette.ELEMENTAL_MIDNIGHT: "Midnight elemental (cool-toned elements)",
ZodiacPalette.ELEMENTAL_NEON: "Neon elemental (electric element colors)",
ZodiacPalette.ELEMENTAL_SEPIA: "Sepia elemental (warm-toned elements)",
# Data science palettes
ZodiacPalette.VIRIDIS: "Viridis (purple→green→yellow, colorblind-friendly)",
ZodiacPalette.PLASMA: "Plasma (blue→purple→orange→yellow, vibrant)",
ZodiacPalette.INFERNO: "Inferno (black→red→orange→yellow, dramatic)",
ZodiacPalette.MAGMA: "Magma (black→purple→pink→yellow, subtle)",
ZodiacPalette.CIVIDIS: "Cividis (blue→yellow, CVD-optimized)",
ZodiacPalette.TURBO: "Turbo (rainbow, improved Google palette)",
ZodiacPalette.COOLWARM: "Coolwarm (blue→white→red, diverging)",
ZodiacPalette.SPECTRAL: "Spectral (red→yellow→green→blue, diverging)",
}
return descriptions.get(palette, "Unknown palette")
# ============================================================================
# ASPECT PALETTES
# ============================================================================
[docs]
class AspectPalette(StrEnum):
"""Available color palettes for aspect lines."""
# Base/theme palettes
CLASSIC = "classic"
DARK = "dark"
MIDNIGHT = "midnight"
NEON = "neon"
SEPIA = "sepia"
PASTEL = "pastel"
CELESTIAL = "celestial"
# Monochromatic variants
GREYSCALE = "greyscale"
BLUES = "blues"
PURPLES = "purples"
EARTH_TONES = "earth_tones"
# Data science palettes
VIRIDIS = "viridis"
PLASMA = "plasma"
INFERNO = "inferno"
MAGMA = "magma"
CIVIDIS = "cividis"
TURBO = "turbo"
[docs]
@lru_cache(maxsize=128)
def get_aspect_palette_colors(palette: AspectPalette) -> dict[str, str]:
"""
Get aspect colors for a given palette.
Returns a dictionary mapping aspect names to hex colors.
Includes Conjunction, Sextile, Square, Trine, Opposition,
and minor aspects (Semisextile, Semisquare, Sesquisquare, Quincunx).
Results are cached in memory for performance.
Args:
palette: The aspect palette to use
Returns:
Dictionary mapping aspect names to hex color strings
"""
if palette == AspectPalette.CLASSIC:
# Registry defaults (from core/registry.py)
return {
"Conjunction": "#34495E",
"Sextile": "#27AE60",
"Square": "#F39C12",
"Trine": "#3498DB",
"Opposition": "#E74C3C",
"Semisextile": "#95A5A6",
"Semisquare": "#E67E22",
"Sesquisquare": "#D68910",
"Quincunx": "#9B59B6",
}
elif palette == AspectPalette.DARK:
# Dark theme: bright accents on dark backgrounds
return {
"Conjunction": "#FFD700",
"Sextile": "#95E1D3",
"Square": "#FF6B9D",
"Trine": "#4ECDC4",
"Opposition": "#FF6B6B",
"Semisextile": "#888888",
"Semisquare": "#FF9999",
"Sesquisquare": "#FFAA99",
"Quincunx": "#BB88CC",
}
elif palette == AspectPalette.MIDNIGHT:
# Midnight theme: gold and distinct blues/cyans for clarity
return {
"Conjunction": "#FFD700", # Gold (bright, stands out)
"Sextile": "#00CED1", # Dark turquoise (distinct from other blues)
"Square": "#FFA500", # Orange (contrast to blues)
"Trine": "#4169E1", # Royal blue (distinct from cyan)
"Opposition": "#DC143C", # Crimson (distinct from other colors)
"Semisextile": "#7B8FA0", # Slate gray
"Semisquare": "#FF8C00", # Dark orange
"Sesquisquare": "#FFB347", # Light orange
"Quincunx": "#9370DB", # Medium purple
}
elif palette == AspectPalette.NEON:
# Neon theme: cyberpunk bright colors
return {
"Conjunction": "#FFFF00",
"Sextile": "#39FF14",
"Square": "#FF1493",
"Trine": "#00FFFF",
"Opposition": "#FF00FF",
"Semisextile": "#00FF88",
"Semisquare": "#FF6600",
"Sesquisquare": "#FF9900",
"Quincunx": "#CC00FF",
}
elif palette == AspectPalette.SEPIA:
# Sepia theme: warm browns with more contrast
return {
"Conjunction": "#654321", # Dark brown (distinct)
"Sextile": "#D2691E", # Chocolate (orange-brown, distinct)
"Square": "#8B4513", # Saddle brown (medium)
"Trine": "#CD853F", # Peru (lighter tan)
"Opposition": "#A0522D", # Sienna (reddish brown)
"Semisextile": "#C4A582", # Tan
"Semisquare": "#DEB887", # Burlywood
"Sesquisquare": "#F4A460", # Sandy brown
"Quincunx": "#BC8F8F", # Rosy brown
}
elif palette == AspectPalette.PASTEL:
# Pastel theme: soft but distinct colors
return {
"Conjunction": "#B39EB5", # Pastel purple (distinct)
"Sextile": "#77DD77", # Pastel green (bright enough to see)
"Square": "#FFB347", # Pastel orange (warm, stands out)
"Trine": "#779ECB", # Pastel blue (cool, distinct)
"Opposition": "#FF6961", # Pastel red (distinct warm)
"Semisextile": "#CFCFC4", # Pastel gray
"Semisquare": "#FDFD96", # Pastel yellow
"Sesquisquare": "#FFD1DC", # Pastel pink
"Quincunx": "#C5A3FF", # Pastel lavender
}
elif palette == AspectPalette.CELESTIAL:
# Celestial theme: cosmic purples and gold
return {
"Conjunction": "#FFD700",
"Sextile": "#DDA0DD",
"Square": "#BA55D3",
"Trine": "#9370DB",
"Opposition": "#DA70D6",
"Semisextile": "#B8A0C0",
"Semisquare": "#C888D0",
"Sesquisquare": "#B878C0",
"Quincunx": "#A868B0",
}
elif palette == AspectPalette.GREYSCALE:
# Monochromatic greyscale
return {
"Conjunction": "#333333",
"Sextile": "#666666",
"Square": "#555555",
"Trine": "#777777",
"Opposition": "#444444",
"Semisextile": "#999999",
"Semisquare": "#888888",
"Sesquisquare": "#888888",
"Quincunx": "#888888",
}
elif palette == AspectPalette.BLUES:
# Monochromatic blues
return {
"Conjunction": "#1A3A52",
"Sextile": "#5B9BD5",
"Square": "#2E5F8F",
"Trine": "#70ADE3",
"Opposition": "#1F4E78",
"Semisextile": "#8BBDEA",
"Semisquare": "#4A7EAD",
"Sesquisquare": "#3D6A99",
"Quincunx": "#6098C7",
}
elif palette == AspectPalette.PURPLES:
# Monochromatic purples
return {
"Conjunction": "#5B2C6F",
"Sextile": "#9B59B6",
"Square": "#7D3C98",
"Trine": "#A569BD",
"Opposition": "#6C3483",
"Semisextile": "#BB8FCE",
"Semisquare": "#8E44AD",
"Sesquisquare": "#7D3C98",
"Quincunx": "#9B59B6",
}
elif palette == AspectPalette.EARTH_TONES:
# Warm earth tones
return {
"Conjunction": "#8B4513",
"Sextile": "#6B8E23",
"Square": "#CD853F",
"Trine": "#8FBC8F",
"Opposition": "#D2691E",
"Semisextile": "#BDB76B",
"Semisquare": "#BC8F5F",
"Sesquisquare": "#A0753F",
"Quincunx": "#9A7B4F",
}
elif palette == AspectPalette.VIRIDIS:
# Viridis: purple → green → yellow
return {
"Conjunction": "#440154",
"Sextile": "#22A884",
"Square": "#414487",
"Trine": "#7AD151",
"Opposition": "#FDE724",
"Semisextile": "#2A788E",
"Semisquare": "#5DC863",
"Sesquisquare": "#B8DE29",
"Quincunx": "#482475",
}
elif palette == AspectPalette.PLASMA:
# Plasma: blue → purple → orange → yellow
return {
"Conjunction": "#0D0887",
"Sextile": "#B12A90",
"Square": "#6A00A8",
"Trine": "#FCA636",
"Opposition": "#F0F921",
"Semisextile": "#8F0DA4",
"Semisquare": "#E16462",
"Sesquisquare": "#FCCE25",
"Quincunx": "#41049D",
}
elif palette == AspectPalette.INFERNO:
# Inferno: black → red → orange → yellow
return {
"Conjunction": "#000004",
"Sextile": "#CF4446",
"Square": "#781C6D",
"Trine": "#FB9A06",
"Opposition": "#FCFFA4",
"Semisextile": "#A52C60",
"Semisquare": "#ED6925",
"Sesquisquare": "#F7D03C",
"Quincunx": "#4A0C6B",
}
elif palette == AspectPalette.MAGMA:
# Magma: black → purple → pink → yellow
return {
"Conjunction": "#000004",
"Sextile": "#982D80",
"Square": "#5F187F",
"Trine": "#EB5760",
"Opposition": "#FCFDBF",
"Semisextile": "#7B2382",
"Semisquare": "#D3436E",
"Sesquisquare": "#F8765C",
"Quincunx": "#410F75",
}
elif palette == AspectPalette.CIVIDIS:
# Cividis: blue → yellow (CVD-friendly)
return {
"Conjunction": "#00204C",
"Sextile": "#73807D",
"Square": "#00447A",
"Trine": "#C5AC83",
"Opposition": "#FFEA46",
"Semisextile": "#4E6B7C",
"Semisquare": "#9B9680",
"Sesquisquare": "#E5C482",
"Quincunx": "#25567B",
}
elif palette == AspectPalette.TURBO:
# Turbo: rainbow (blue → cyan → green → yellow → red)
return {
"Conjunction": "#30123B",
"Sextile": "#72FE5E",
"Square": "#1FAAD2",
"Trine": "#FABA39",
"Opposition": "#CA2A04",
"Semisextile": "#1AE4B6",
"Semisquare": "#C8EF34",
"Sesquisquare": "#F66B19",
"Quincunx": "#4662D7",
}
else:
# Fallback to classic
return get_aspect_palette_colors(AspectPalette.CLASSIC)
[docs]
def build_aspect_styles_from_palette(palette: AspectPalette | str) -> dict[str, dict]:
"""
Build complete aspect styling dict with palette colors + registry line styles.
This merges palette colors with the ASPECT_REGISTRY's line_width and dash_pattern,
ensuring themes only change colors while preserving the registry's line styling.
Args:
palette: The aspect palette to use for colors
Returns:
Dictionary mapping aspect names to style dicts with "color", "width", "dash" keys
"""
from stellium.core.registry import ASPECT_REGISTRY
# Get colors from palette
if isinstance(palette, str):
palette = AspectPalette(palette)
colors = get_aspect_palette_colors(palette)
# Build styles using registry metadata for width/dash
styles = {}
for aspect_info in ASPECT_REGISTRY.values():
if aspect_info.category in ["Major", "Minor"]:
color = colors.get(aspect_info.name, aspect_info.color)
styles[aspect_info.name] = {
"color": color,
"width": aspect_info.metadata.get("line_width", 1.5),
"dash": aspect_info.metadata.get("dash_pattern", "1,0"),
}
return styles
[docs]
def get_aspect_palette_description(palette: AspectPalette) -> str:
"""
Get a human-readable description of an aspect palette.
Args:
palette: The palette to describe
Returns:
Description string
"""
descriptions = {
AspectPalette.CLASSIC: "Classic (registry defaults)",
AspectPalette.DARK: "Dark (bright accents for dark backgrounds)",
AspectPalette.MIDNIGHT: "Midnight (gold and cool blues)",
AspectPalette.NEON: "Neon (cyberpunk bright colors)",
AspectPalette.SEPIA: "Sepia (warm browns)",
AspectPalette.PASTEL: "Pastel (soft gentle colors)",
AspectPalette.CELESTIAL: "Celestial (cosmic purples and gold)",
AspectPalette.GREYSCALE: "Greyscale (monochromatic greys)",
AspectPalette.BLUES: "Blues (monochromatic blue tones)",
AspectPalette.PURPLES: "Purples (monochromatic purple tones)",
AspectPalette.EARTH_TONES: "Earth Tones (warm natural colors)",
AspectPalette.VIRIDIS: "Viridis (purple→green→yellow, perceptually uniform)",
AspectPalette.PLASMA: "Plasma (blue→purple→orange→yellow)",
AspectPalette.INFERNO: "Inferno (black→red→orange→yellow)",
AspectPalette.MAGMA: "Magma (black→purple→pink→yellow)",
AspectPalette.CIVIDIS: "Cividis (blue→yellow, CVD-optimized)",
AspectPalette.TURBO: "Turbo (improved rainbow)",
}
return descriptions.get(palette, "Unknown palette")
# ============================================================================
# PLANET GLYPH PALETTES
# ============================================================================
[docs]
class PlanetGlyphPalette(StrEnum):
"""Available color palettes for planet glyphs."""
# Monochromatic (theme-based)
DEFAULT = "default" # Uses theme's planet glyph color
# Astrological categorization
ELEMENT = "element" # Color by element (fire/earth/air/water)
SIGN_RULER = "sign_ruler" # Color by traditional rulership
PLANET_TYPE = "planet_type" # Traditional vs Modern vs Asteroids
LUMINARIES = "luminaries" # Sun/Moon special, others neutral
# Vibrant categorical
RAINBOW = "rainbow" # Each planet gets a different color
CHAKRA = "chakra" # Based on chakra correspondences
# Data science palettes
VIRIDIS = "viridis"
PLASMA = "plasma"
INFERNO = "inferno"
TURBO = "turbo"
# Planet categorizations for palette mapping
PLANET_ELEMENTS = {
"Sun": "fire",
"Moon": "water",
"Mercury": "air",
"Venus": "earth",
"Mars": "fire",
"Jupiter": "fire",
"Saturn": "earth",
"Uranus": "air",
"Neptune": "water",
"Pluto": "water",
}
PLANET_TYPES = {
"Sun": "luminary",
"Moon": "luminary",
"Mercury": "traditional",
"Venus": "traditional",
"Mars": "traditional",
"Jupiter": "traditional",
"Saturn": "traditional",
"Uranus": "modern",
"Neptune": "modern",
"Pluto": "modern",
"Chiron": "centaur",
"Ceres": "asteroid",
"Pallas": "asteroid",
"Juno": "asteroid",
"Vesta": "asteroid",
"True Node": "node",
"Mean Node": "node",
"South Node": "node",
"Mean Apogee": "point",
"True Apogee": "point",
}
[docs]
def get_planet_glyph_color(
planet_name: str,
palette: PlanetGlyphPalette,
theme_default_color: str = "#222222",
) -> str:
"""
Get the color for a planet glyph based on palette.
Args:
planet_name: Name of the planet/object
palette: The palette to use
theme_default_color: Default color from theme (used for DEFAULT palette)
Returns:
Hex color string
"""
if palette == PlanetGlyphPalette.DEFAULT:
return theme_default_color
elif palette == PlanetGlyphPalette.ELEMENT:
# Color by element
element_colors = {
"fire": "#E74C3C", # Red
"earth": "#27AE60", # Green
"air": "#3498DB", # Blue
"water": "#9B59B6", # Purple
}
element = PLANET_ELEMENTS.get(planet_name)
return element_colors.get(element, theme_default_color)
elif palette == PlanetGlyphPalette.SIGN_RULER:
# Color by traditional rulership (simplified)
ruler_colors = {
"Sun": "#FFD700", # Gold
"Moon": "#C0C0C0", # Silver
"Mercury": "#FFA500", # Orange
"Venus": "#FF69B4", # Pink
"Mars": "#DC143C", # Crimson
"Jupiter": "#4169E1", # Royal Blue
"Saturn": "#2F4F4F", # Dark Slate
"Uranus": "#00CED1", # Dark Turquoise
"Neptune": "#7B68EE", # Medium Slate Blue
"Pluto": "#8B0000", # Dark Red
}
return ruler_colors.get(planet_name, theme_default_color)
elif palette == PlanetGlyphPalette.PLANET_TYPE:
# Color by planet type
type_colors = {
"luminary": "#FFD700", # Gold
"traditional": "#4169E1", # Royal Blue
"modern": "#9370DB", # Medium Purple
"centaur": "#20B2AA", # Light Sea Green
"asteroid": "#CD853F", # Peru
"node": "#A9A9A9", # Dark Grey
"point": "#DDA0DD", # Plum
}
planet_type = PLANET_TYPES.get(planet_name)
return type_colors.get(planet_type, theme_default_color)
elif palette == PlanetGlyphPalette.LUMINARIES:
# Sun and Moon special, others neutral
if planet_name == "Sun":
return "#FFD700" # Gold
elif planet_name == "Moon":
return "#C0C0C0" # Silver
else:
return theme_default_color
elif palette == PlanetGlyphPalette.RAINBOW:
# Each planet gets a different rainbow color
rainbow_colors = {
"Sun": "#FF0000", # Red
"Moon": "#FF7F00", # Orange
"Mercury": "#FFFF00", # Yellow
"Venus": "#00FF00", # Green
"Mars": "#0000FF", # Blue
"Jupiter": "#4B0082", # Indigo
"Saturn": "#9400D3", # Violet
"Uranus": "#FF1493", # Deep Pink
"Neptune": "#00CED1", # Dark Turquoise
"Pluto": "#8B4513", # Saddle Brown
}
return rainbow_colors.get(planet_name, theme_default_color)
elif palette == PlanetGlyphPalette.CHAKRA:
# Based on planetary chakra correspondences
chakra_colors = {
"Sun": "#FDB827", # Solar Plexus - Yellow
"Moon": "#C77DFF", # Crown - Violet/White
"Mercury": "#00BBF9", # Throat - Blue
"Venus": "#06D6A0", # Heart - Green
"Mars": "#E63946", # Root - Red
"Jupiter": "#9B59B6", # Third Eye - Indigo
"Saturn": "#495057", # Root (grounding) - Dark
"Uranus": "#00F5FF", # Higher Throat - Cyan
"Neptune": "#DA70D6", # Crown - Orchid
"Pluto": "#8B0000", # Root (transformation) - Dark Red
}
return chakra_colors.get(planet_name, theme_default_color)
elif palette == PlanetGlyphPalette.VIRIDIS:
# Viridis colormap - 10 colors for major planets
viridis_10 = [
"#440154",
"#482475",
"#414487",
"#2A788E",
"#22A884",
"#42BE71",
"#7AD151",
"#BBDF27",
"#FDE724",
"#FDE724",
]
planet_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
]
if planet_name in planet_order:
return viridis_10[planet_order.index(planet_name)]
return theme_default_color
elif palette == PlanetGlyphPalette.PLASMA:
# Plasma colormap - 10 colors
plasma_10 = [
"#0D0887",
"#5302A3",
"#8B0AA5",
"#B83289",
"#DB5C68",
"#F48849",
"#FEBC2A",
"#F0F921",
"#F0F921",
"#F0F921",
]
planet_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
]
if planet_name in planet_order:
return plasma_10[planet_order.index(planet_name)]
return theme_default_color
elif palette == PlanetGlyphPalette.INFERNO:
# Inferno colormap - 10 colors
inferno_10 = [
"#000004",
"#320A5A",
"#781C6D",
"#BB3754",
"#ED6925",
"#FB9A06",
"#F7D03C",
"#FCFFA4",
"#FCFFA4",
"#FCFFA4",
]
planet_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
]
if planet_name in planet_order:
return inferno_10[planet_order.index(planet_name)]
return theme_default_color
elif palette == PlanetGlyphPalette.TURBO:
# Turbo colormap - 10 colors
turbo_10 = [
"#30123B",
"#4662D7",
"#1AE4B6",
"#72FE5E",
"#C8EF34",
"#FABA39",
"#F66B19",
"#CA2A04",
"#7A0403",
"#7A0403",
]
planet_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
]
if planet_name in planet_order:
return turbo_10[planet_order.index(planet_name)]
return theme_default_color
else:
return theme_default_color
[docs]
def get_planet_glyph_palette_description(palette: PlanetGlyphPalette) -> str:
"""
Get a human-readable description of a planet glyph palette.
Args:
palette: The palette to describe
Returns:
Description string
"""
descriptions = {
PlanetGlyphPalette.DEFAULT: "Default (theme color)",
PlanetGlyphPalette.ELEMENT: "Element (fire/earth/air/water)",
PlanetGlyphPalette.SIGN_RULER: "Rulership (traditional planetary colors)",
PlanetGlyphPalette.PLANET_TYPE: "Planet Type (luminary/traditional/modern/etc.)",
PlanetGlyphPalette.LUMINARIES: "Luminaries (Sun/Moon special, others neutral)",
PlanetGlyphPalette.RAINBOW: "Rainbow (each planet different color)",
PlanetGlyphPalette.CHAKRA: "Chakra (planetary-chakra correspondences)",
PlanetGlyphPalette.VIRIDIS: "Viridis (perceptually uniform)",
PlanetGlyphPalette.PLASMA: "Plasma (vibrant gradient)",
PlanetGlyphPalette.INFERNO: "Inferno (dramatic gradient)",
PlanetGlyphPalette.TURBO: "Turbo (improved rainbow)",
}
return descriptions.get(palette, "Unknown palette")
# ============================================================================
# COLOR UTILITIES FOR ADAPTIVE/CONTRAST-AWARE COLORING
# ============================================================================
[docs]
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""
Convert hex color to RGB tuple.
Args:
hex_color: Hex color string (e.g., "#FF00AA" or "FF00AA")
Returns:
RGB tuple (r, g, b) where each value is 0-255
"""
hex_color = hex_color.lstrip("#")
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
[docs]
def rgb_to_hex(r: int, g: int, b: int) -> str:
"""
Convert RGB values to hex color string.
Args:
r: Red (0-255)
g: Green (0-255)
b: Blue (0-255)
Returns:
Hex color string (e.g., "#FF00AA")
"""
return f"#{r:02X}{g:02X}{b:02X}"
[docs]
def get_luminance(hex_color: str) -> float:
"""
Calculate the relative luminance of a color.
Uses WCAG formula for luminance calculation.
Args:
hex_color: Hex color string
Returns:
Relative luminance (0.0 = black, 1.0 = white)
"""
r, g, b = hex_to_rgb(hex_color)
# Convert to 0-1 range
r, g, b = r / 255.0, g / 255.0, b / 255.0
# Apply gamma correction
def gamma_correct(val):
if val <= 0.03928:
return val / 12.92
else:
return ((val + 0.055) / 1.055) ** 2.4
r = gamma_correct(r)
g = gamma_correct(g)
b = gamma_correct(b)
# Calculate luminance
return 0.2126 * r + 0.7152 * g + 0.0722 * b
[docs]
def get_contrast_ratio(color1: str, color2: str) -> float:
"""
Calculate the contrast ratio between two colors.
Args:
color1: First hex color
color2: Second hex color
Returns:
Contrast ratio (1.0 = no contrast, 21.0 = maximum)
"""
lum1 = get_luminance(color1)
lum2 = get_luminance(color2)
lighter = max(lum1, lum2)
darker = min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
[docs]
def adjust_color_for_contrast(
original_color: str,
background_color: str,
min_contrast: float = 4.5,
max_iterations: int = 20,
) -> str:
"""
Adjust a color to ensure minimum contrast against a background.
This algorithm:
1. Checks if original color already has sufficient contrast
2. If not, determines if background is light or dark
3. Adjusts the color's lightness/darkness in the opposite direction
4. Iterates until minimum contrast is achieved
Args:
original_color: The color to adjust (hex)
background_color: The background color (hex)
min_contrast: Minimum WCAG contrast ratio (default 4.5 = WCAG AA)
max_iterations: Maximum adjustment iterations
Returns:
Adjusted hex color that meets minimum contrast
"""
# Check if original already has enough contrast
if get_contrast_ratio(original_color, background_color) >= min_contrast:
return original_color
# Determine if background is light or dark
bg_luminance = get_luminance(background_color)
bg_is_light = bg_luminance > 0.5
# Convert original color to HSL for easier lightness manipulation
r, g, b = hex_to_rgb(original_color)
h, lightness, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)
# Adjust lightness iteratively
step = 0.05
for _ in range(max_iterations):
# If background is light, make color darker; if dark, make color lighter
if bg_is_light:
lightness = max(0.0, lightness - step)
else:
lightness = min(1.0, lightness + step)
# Convert back to RGB then hex
r_adj, g_adj, b_adj = colorsys.hls_to_rgb(h, lightness, s)
adjusted_hex = rgb_to_hex(int(r_adj * 255), int(g_adj * 255), int(b_adj * 255))
# Check if we've achieved minimum contrast
if get_contrast_ratio(adjusted_hex, background_color) >= min_contrast:
return adjusted_hex
# If we've hit the extreme (pure black or white), stop
if lightness <= 0.0 or lightness >= 1.0:
break
# If we couldn't achieve the desired contrast, return pure black or white
return "#000000" if bg_is_light else "#FFFFFF"
[docs]
def get_sign_info_color(
sign_index: int,
zodiac_palette: ZodiacPalette,
background_color: str,
min_contrast: float = 4.5,
) -> str:
"""
Get an adaptive color for sign glyph in planet info stack.
This function:
1. Gets the sign's zodiac wheel color from the palette
2. Adjusts it for contrast against the background
3. Returns a color that's readable while maintaining zodiac color story
Args:
sign_index: Zodiac sign index (0=Aries, 1=Taurus, etc.)
zodiac_palette: The active zodiac palette
background_color: Background color of the planet/info area
min_contrast: Minimum WCAG contrast ratio
Returns:
Hex color for the sign glyph that contrasts with background
"""
# Get the zodiac wheel colors
zodiac_colors = get_palette_colors(zodiac_palette)
sign_color = zodiac_colors[sign_index]
# Adjust for contrast against background
return adjust_color_for_contrast(sign_color, background_color, min_contrast)