├── .gitignore ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── dependencies.py │ ├── exceptions.py │ └── routers │ │ ├── __init__.py │ │ ├── base.py │ │ ├── planets.py │ │ └── socket.py ├── logic │ ├── __init__.py │ ├── ascendant.py │ ├── connection_manager.py │ └── planet.py ├── main.py ├── models │ ├── __init__.py │ ├── ascendant.py │ ├── data.py │ └── planet.py ├── static │ ├── babel.config.json │ ├── dist │ │ ├── index.html │ │ ├── main.bundle.js │ │ └── main.bundle.js.LICENSE.txt │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── index.html │ │ └── main.css │ ├── src │ │ ├── birthchart.js │ │ ├── darkmode.js │ │ ├── main.js │ │ └── svg.js │ └── webpack.config.js ├── templates │ └── index.html └── utils.py ├── build.sh ├── makefile └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | env.py 4 | env/ 5 | __pycache__/ 6 | 7 | node_modules/ 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # astro-clock 2 | Websocket-powered full-stack app that displays the current astrological transits in real time. Built with Python + JS. 3 | 4 | https://astro-clock.com 5 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/susieward/astro-clock/2615eb9ddfed785a3349ac76c0c13a28c19521d2/app/__init__.py -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/susieward/astro-clock/2615eb9ddfed785a3349ac76c0c13a28c19521d2/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from fastapi import Depends 3 | from cerridwen.planets import (Sun, Moon, Mercury, Mars, Venus, Jupiter, Saturn, Uranus, Neptune, Pluto) 4 | from app.logic.planet import PlanetLogic 5 | from app.logic.ascendant import AscendantLogic 6 | from app.models.planet import Planet 7 | from app.utils import create_metaclasses 8 | 9 | def get_planet_classes() -> List: 10 | planet_subclasses = [ 11 | Sun, Moon, Mercury, Mars, Venus, Jupiter, 12 | Saturn, Uranus, Neptune, Pluto 13 | ] 14 | planet_classes = create_metaclasses(Planet, planet_subclasses) 15 | return planet_classes 16 | 17 | 18 | def asc_logic_dependency() -> AscendantLogic: 19 | return AscendantLogic() 20 | 21 | 22 | def planet_logic_dependency( 23 | planet_classes = Depends(get_planet_classes), 24 | asc_logic: AscendantLogic = Depends(asc_logic_dependency) 25 | ) -> PlanetLogic: 26 | return PlanetLogic(planet_classes=planet_classes, asc_logic=asc_logic) 27 | -------------------------------------------------------------------------------- /app/api/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class AppException(Exception): 3 | def __init__(self, message: str, extras = None): 4 | super().__init__(message, extras) 5 | 6 | 7 | class PlanetLogicException(AppException): 8 | def __init__(self, message: str, exc): 9 | extras = { 'exc': exc } 10 | super().__init__(message=message, extras=extras) 11 | 12 | 13 | class AscendantLogicException(AppException): 14 | def __init__(self, message: str, exc): 15 | extras = { 'exc': exc } 16 | super().__init__(message=message, extras=extras) 17 | 18 | 19 | class ConnectionException(AppException): 20 | def __init__(self, message: str, exc): 21 | extras = { 22 | 'exc': exc 23 | } 24 | super().__init__(message, extras) 25 | -------------------------------------------------------------------------------- /app/api/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/susieward/astro-clock/2615eb9ddfed785a3349ac76c0c13a28c19521d2/app/api/routers/__init__.py -------------------------------------------------------------------------------- /app/api/routers/base.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from fastapi.templating import Jinja2Templates 3 | 4 | templates = Jinja2Templates(directory = "app/templates") 5 | 6 | router = APIRouter() 7 | 8 | @router.get('/') 9 | async def index(request: Request): 10 | return templates.TemplateResponse('index.html', { 'request': request }) 11 | -------------------------------------------------------------------------------- /app/api/routers/planets.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, HTTPException, WebSocket 2 | from app.logic.planet import PlanetLogic 3 | 4 | router = APIRouter() 5 | planet_logic = PlanetLogic() 6 | 7 | def error_handler(func): 8 | def with_detail(*args, **kwargs): 9 | try: 10 | return func(*args, **kwargs) 11 | except Exception as e: 12 | raise HTTPException(status_code=500, detail=f'{str(e)}') 13 | return with_detail 14 | 15 | 16 | @error_handler 17 | @router.get('/planets') 18 | async def get_planets(): 19 | return planet_logic.get_planets() 20 | 21 | 22 | @error_handler 23 | @router.post('/planets') 24 | async def get_planets_from_dates(request: Request): 25 | data = await request.body() 26 | start = data.get('start') 27 | end = data.get('end') or None 28 | return planet_logic.get_planets() 29 | 30 | 31 | @error_handler 32 | @router.get('/planet/{id}') 33 | async def get_planet(id): 34 | return planet_logic.get_planet(int(id)) 35 | -------------------------------------------------------------------------------- /app/api/routers/socket.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, WebSocket, Depends 2 | 3 | from app.api.dependencies import planet_logic_dependency, asc_logic_dependency 4 | from app.logic.connection_manager import ConnectionManager 5 | from app.logic.planet import PlanetLogic 6 | from app.api.exceptions import PlanetLogicException 7 | 8 | router = APIRouter() 9 | 10 | @router.websocket("/ws/{client_id}") 11 | async def socket( 12 | websocket: WebSocket, 13 | client_id: str, 14 | planet_logic: PlanetLogic = Depends(planet_logic_dependency) 15 | ): 16 | on_receive = planet_logic.get_planets 17 | 18 | async with ConnectionManager(websocket, on_receive, client_id) as conn: 19 | await conn.receive_json() 20 | -------------------------------------------------------------------------------- /app/logic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/susieward/astro-clock/2615eb9ddfed785a3349ac76c0c13a28c19521d2/app/logic/__init__.py -------------------------------------------------------------------------------- /app/logic/ascendant.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cerridwen.utils import iso2jd 4 | from app.models.ascendant import Ascendant 5 | from app.models.data import AscData 6 | from app.api.exceptions import AscendantLogicException 7 | 8 | class AscendantLogic: 9 | def get_ascendant(self, data) -> AscData: 10 | try: 11 | long = data.get('long') 12 | lat = data.get('lat') 13 | date = data.get('date') or None 14 | 15 | asc = self.init_asc(long, lat, date) 16 | data = self.build_asc_data(asc) 17 | return data 18 | except Exception as e: 19 | raise AscendantLogicException(message='AscLogic error', exc=e) 20 | 21 | def init_asc(self, long, lat, date = None) -> Ascendant: 22 | if date is not None: date = iso2jd(date) 23 | return Ascendant.__call__(long=long, lat=lat, jd=date) 24 | 25 | def build_asc_data(self, asc) -> AscData: 26 | label = f'{asc.name()} ({asc.degrees()})°' 27 | label_sm = f'ASC {asc.degrees()}°' 28 | 29 | asc_dict = { 30 | 'name': asc.name(), 31 | 'position': asc.position_str(), 32 | 'label': label, 33 | 'label_sm': label_sm, 34 | 'position_formatted': asc.position_formatted(), 35 | 'sign': asc.sign(), 36 | 'houses': asc.houses(), 37 | 'deg': asc.degrees() 38 | } 39 | return AscData(**asc_dict) 40 | -------------------------------------------------------------------------------- /app/logic/connection_manager.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from fastapi import WebSocket 4 | from fastapi.encoders import jsonable_encoder 5 | from app.api.exceptions import ConnectionException, PlanetLogicException 6 | 7 | class ConnectionManager: 8 | def __init__(self, websocket: WebSocket, on_receive, client_id): 9 | self.websocket = websocket 10 | self._on_receive = on_receive 11 | self._client_id = client_id 12 | 13 | async def __aenter__(self): 14 | await self.websocket.accept() 15 | return self 16 | 17 | async def __aexit__(self, exc_type, exc_value, traceback): 18 | print('exit called: ', exc_type, exc_value, traceback) 19 | try: 20 | await self.websocket.close() 21 | except Exception as e: 22 | print(e) 23 | pass 24 | finally: 25 | return True 26 | 27 | async def receive_json(self): 28 | async for message in self.websocket.iter_json(): 29 | await self.handle_json(message) 30 | 31 | async def handle_json(self, message): 32 | try: 33 | response = self._on_receive(message) 34 | json = jsonable_encoder(response) 35 | return await self.websocket.send_json(json) 36 | except PlanetLogicException as e: 37 | print(e) 38 | traceback.print_exc() 39 | exc_type, exc_value, tb = sys.exc_info() 40 | data = { 41 | 'error': True, 42 | 'exc_type': str(exc_type), 43 | 'exc_value': str(exc_value), 44 | 'traceback': traceback.format_exc() 45 | } 46 | err_json = jsonable_encoder(data) 47 | return await self.websocket.send_json(err_json) 48 | except Exception as e: 49 | raise ConnectionException(message='Connection Error', exc=e) 50 | -------------------------------------------------------------------------------- /app/logic/planet.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, List 3 | from cerridwen.utils import iso2jd 4 | from app.api.exceptions import PlanetLogicException 5 | from app.logic.ascendant import AscendantLogic 6 | from app.models.data import PlanetData 7 | from app.utils import glyphs 8 | 9 | class PlanetLogic: 10 | def __init__(self, planet_classes, asc_logic: AscendantLogic) -> None: 11 | self._planet_classes = planet_classes 12 | self._asc_logic = asc_logic 13 | 14 | def get_planets(self, data: Dict) -> List: 15 | try: 16 | date = data.get('date') or None 17 | long = data.get('long') or None 18 | lat = data.get('lat') or None 19 | 20 | if date is not None: 21 | date = iso2jd(date) 22 | 23 | planets = self.init_planets(date) 24 | results = [self.get_planet_data(planet, jd=date) for planet in planets] 25 | 26 | if long and lat: 27 | asc = self._asc_logic.get_ascendant(data) 28 | return [asc, *results] 29 | 30 | return results 31 | except Exception as e: 32 | raise PlanetLogicException(message='PlanetLogic error', exc=e) 33 | 34 | def init_planets(self, date) -> List: 35 | planets = [self._planet_classes[i].__call__(planet_id=i, jd=date) for i, planet in enumerate(self._planet_classes)] 36 | return planets 37 | 38 | def get_planet_data(self, planet, jd) -> PlanetData: 39 | name = planet.name() 40 | degrees = planet.degrees() 41 | label = f'{name} ({degrees}°)' 42 | label_sm = f'{glyphs[planet.id]} {degrees}°' 43 | phase_val = None 44 | dignity = None 45 | 46 | if 'dignity' in planet.__class__.__dict__.keys(): 47 | dignity = planet.dignity(jd=jd) 48 | 49 | if name == 'Moon': 50 | current_phase = planet.phase(jd=jd) 51 | trend, shape, quarter, quarter_english = current_phase 52 | phase_val = f'{trend} {shape}' 53 | 54 | if quarter_english is not None: 55 | phase_val = f'{phase_val} {quarter_english}' 56 | 57 | planet_data = PlanetData( 58 | name=name, 59 | position=planet.position_str(), 60 | position_formatted=planet.position_formatted(), 61 | id=planet.id, 62 | label=label, 63 | label_sm=label_sm, 64 | sign=planet.sign(), 65 | deg=degrees, 66 | phase=phase_val, 67 | dignity=dignity 68 | ) 69 | return planet_data 70 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.staticfiles import StaticFiles 3 | from fastapi.middleware.gzip import GZipMiddleware 4 | from app.api.routers import base, socket 5 | 6 | def get_app(): 7 | app = FastAPI() 8 | app.add_middleware(GZipMiddleware, minimum_size=1000) 9 | app.mount("/static", StaticFiles(directory = "app/static"), name = "static") 10 | 11 | app.include_router(base.router) 12 | app.include_router(socket.router) 13 | return app 14 | 15 | app = get_app() 16 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/susieward/astro-clock/2615eb9ddfed785a3349ac76c0c13a28c19521d2/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/ascendant.py: -------------------------------------------------------------------------------- 1 | # cerridwen Copyright (c) 2014 Leslie P. Polzer 2 | # https://github.com/skypher/cerridwen 3 | from typing import List 4 | from cerridwen.planets import Ascendant as AscendantBase, PlanetLongitude 5 | import swisseph as sweph 6 | from app.models.data import HouseData 7 | from app.utils import roman_nums 8 | 9 | class Ascendant(AscendantBase): 10 | def position_str(self) -> str: 11 | return str(self.position()) 12 | 13 | def position_formatted(self): 14 | values = self.position_str().split(' ') 15 | degrees = values[0] 16 | sign = self.sign() 17 | formatted = f'{degrees}° {sign} {values[2]} {values[3]}' 18 | return formatted 19 | 20 | def degrees(self): 21 | pos_list = self.position_str().split(' ') 22 | return int(pos_list[0]) 23 | 24 | def houses(self, jd = None) -> List: 25 | if jd is None: jd = self.jd 26 | results = sweph.houses(jd, self.lat, self.long)[0] 27 | houses = [] 28 | for i, result in enumerate(results): 29 | pos = PlanetLongitude(result) 30 | pos_arr = str(pos).split(' ') 31 | position = f'{pos_arr[0]}°{pos_arr[2]}' 32 | deg = int(pos_arr[0]) 33 | house_num = i + 1 34 | roman_num = roman_nums[i] 35 | name = f'House {roman_num}' 36 | data = HouseData( 37 | name=name, 38 | number=house_num, 39 | label=roman_num, 40 | position=position, 41 | sign=pos.sign, 42 | deg=deg 43 | ) 44 | houses.append(data) 45 | return houses 46 | -------------------------------------------------------------------------------- /app/models/data.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class Data(BaseModel): 5 | name: str 6 | position: str 7 | position_formatted: Optional[str] = None 8 | sign: str 9 | deg: int 10 | label: str 11 | label_sm: Optional[str] = None 12 | 13 | 14 | class PlanetData(Data): 15 | id: int 16 | phase: Optional[str] = None 17 | dignity: Optional[str] = None 18 | 19 | 20 | class HouseData(Data): 21 | number: int 22 | 23 | 24 | class AscData(Data): 25 | houses: List[HouseData] 26 | -------------------------------------------------------------------------------- /app/models/planet.py: -------------------------------------------------------------------------------- 1 | # cerridwen Copyright (c) 2014 Leslie P. Polzer 2 | # https://github.com/skypher/cerridwen 3 | from cerridwen.planets import Planet as PlanetBase 4 | from app.utils import glyphs 5 | 6 | class Planet(PlanetBase): 7 | def longitude(self, jd = None): 8 | if jd is None: jd = self.jd 9 | long = super().longitude(jd) 10 | return long[0] 11 | 12 | def latitude(self, jd = None): 13 | if jd is None: jd = self.jd 14 | lat = super().latitude(jd) 15 | return lat[1] 16 | 17 | def angle(self, planet, jd = None): 18 | if jd is None: jd = self.jd 19 | planet_long = planet.longitude(jd) 20 | angle = (self.longitude(jd) - planet_long[0]) % 360 21 | return round(angle) 22 | 23 | def position_str(self): 24 | return str(self.position()) 25 | 26 | def position_formatted(self): 27 | values = self.position_str().split(' ') 28 | degrees = values[0] 29 | #sign = self.sign() 30 | formatted = f'{degrees}° {values[1]} {values[2]} {values[3]}' 31 | return formatted 32 | 33 | def degrees(self): 34 | values = self.position_str().split(' ') 35 | return int(values[0]) 36 | -------------------------------------------------------------------------------- /app/static/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /app/static/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Astro Clock 10 | 11 | 12 | 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |

Astro Clock

29 |
30 |
31 | 32 |
33 |
x
34 |
35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /app/static/dist/main.bundle.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Lodash 4 | * Copyright OpenJS Foundation and other contributors 5 | * Released under MIT license 6 | * Based on Underscore.js 1.8.3 7 | * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 8 | */ 9 | -------------------------------------------------------------------------------- /app/static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-clock-js", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack --config ./webpack.config.js", 9 | "serve": "webpack-dev-server" 10 | }, 11 | "author": "Susie Ward", 12 | "license": "ISC", 13 | "dependencies": { 14 | "city-timezones": "^1.2.0" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.15.0", 18 | "@babel/preset-env": "^7.15.0", 19 | "babel-loader": "^8.2.2", 20 | "css-loader": "^6.2.0", 21 | "html-webpack-plugin": "^5.3.2", 22 | "style-loader": "^3.2.1", 23 | "webpack": "^5.51.1", 24 | "webpack-cli": "^4.8.0", 25 | "webpack-dev-server": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/static/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Astro Clock 10 | 11 | 12 | 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |

Astro Clock

29 |
30 |
31 | 32 |
33 |
x
34 |
35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /app/static/public/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | :root { 6 | --red: #cf222e; 7 | --blue: #0550ae; 8 | --almost-black: #222; 9 | --darkgrey: #24292f; 10 | --grey: #6e7781; 11 | --lightgrey: #999FA6; 12 | --lightergrey: #F5F8FA; 13 | --linen-light: #FCF6F0; 14 | 15 | --bgcolor: var(--linen-light); 16 | --main-text-color: var(--darkgrey); 17 | --main-font: 'Menlo'; 18 | --main-font-size: 16px; 19 | --main-line-height: 24px; 20 | --h2-color: var(--darkgrey); 21 | --mode: var(--lightgrey); 22 | --sidenav-bg: var(--almost-black); 23 | --sidenav-text: var(--lightgrey); 24 | 25 | --svg-circle-color: var(--lightgrey); 26 | --svg-text-color: var(--darkgrey); 27 | --house-text-color: var(--grey); 28 | --planet-text-color: var(--darkgrey); 29 | --planet-point-color: #393E43; 30 | --sign-text-color: var(--grey); 31 | --sign-path-color: var(--lightgrey); 32 | --house-path-color: var(--grey); 33 | 34 | --opposite: var(--almost-black); 35 | } 36 | 37 | #Conjunct { 38 | color: green; 39 | stroke: green; 40 | } 41 | #Sextile { 42 | color: magenta; 43 | stroke: magenta; 44 | } 45 | #Square { 46 | color: var(--red); 47 | stroke: var(--red); 48 | } 49 | #Trine { 50 | color: var(--blue); 51 | stroke: var(--blue); 52 | } 53 | #Opposite { 54 | color: var(--opposite); 55 | stroke: var(--opposite); 56 | } 57 | 58 | #mode { 59 | position: absolute; 60 | top: 10px; 61 | right: 30px; 62 | font-family: HelveticaNeue-CondensedBold,Futura-Medium,-apple-system,'Arial Rounded MT Bold',system-ui,Ubuntu,sans-serif,'Arial Unicode MS','Zapf Dingbats','Segoe UI Emoji','Segoe UI Symbol',Noto Color Emoji, NotoColorEmoji,EmojiSymbols,Symbola,Noto,'Android Emoji',AndroidEmoji,'lucida grande',tahoma,verdana,arial,AppleColorEmoji,'Apple Color Emoji'; 63 | font-size: 32px; 64 | cursor: pointer; 65 | color: var(--mode); 66 | } 67 | 68 | body { 69 | margin: 0; 70 | padding: 0; 71 | font-family: var(--main-font); 72 | -webkit-font-smoothing: antialiased; 73 | -moz-osx-font-smoothing: grayscale; 74 | color: var(--main-text-color); 75 | background-color: var(--bgcolor); 76 | width: 100vw; 77 | height: 100vh; 78 | font-size: var(--main-font-size); 79 | line-height: var(--main-line-height); 80 | } 81 | 82 | .theme-dark { 83 | --main-text-color: var(--grey); 84 | --bgcolor: var(--almost-black); 85 | --svg-circle-color: var(--grey); 86 | --sign-text-color: var(--grey); 87 | --svg-text-color: var(--grey); 88 | --planet-text-color: var(--grey); 89 | --sign-path-color: var(--grey); 90 | --opposite: var(--lightgrey); 91 | --mode: var(--grey); 92 | --planet-point-color: var(--grey); 93 | --house-path-color: var(--grey); 94 | --sidenav-bg: var(--darkgrey); 95 | --sidenav-text: var(--lightgrey); 96 | } 97 | 98 | svg circle { 99 | fill: transparent; 100 | stroke-width: 1; 101 | cursor: 'pointer'; 102 | stroke: var(--svg-circle-color); 103 | } 104 | 105 | svg circle#planet-point { 106 | stroke: var(--planet-point-color); 107 | fill: var(--planet-point-color); 108 | } 109 | 110 | svg text { 111 | font: '14px Menlo'; 112 | } 113 | 114 | svg text tspan#planet-text { 115 | fill: var(--planet-text-color); 116 | } 117 | 118 | svg text tspan#house-text { 119 | fill: var(--house-text-color); 120 | font-size: 14px; 121 | } 122 | 123 | svg path#house-path { 124 | stroke: var(--house-path-color); 125 | } 126 | 127 | svg path#sign-path { 128 | stroke: var(--sign-path-color); 129 | } 130 | 131 | svg text tspan#sign-text { 132 | fill: var(--sign-text-color); 133 | font-family: HelveticaNeue-CondensedBold,Futura-Medium,-apple-system,'Arial Rounded MT Bold',system-ui,Ubuntu,sans-serif,'Arial Unicode MS','Zapf Dingbats','Segoe UI Emoji','Segoe UI Symbol',Noto Color Emoji, NotoColorEmoji,EmojiSymbols,Symbola,Noto,'Android Emoji',AndroidEmoji,'lucida grande',tahoma,verdana,arial,AppleColorEmoji,'Apple Color Emoji'; 134 | color: var(--sign-text-color); 135 | font-size:24px; 136 | line-height:24px; 137 | font-weight:normal; 138 | } 139 | 140 | #app { 141 | max-width: 100%; 142 | height: 100%; 143 | max-height: 100%; 144 | position: relative; 145 | overflow: hidden; 146 | } 147 | 148 | .header { 149 | display: grid; 150 | grid-template-areas: "title left-section"; 151 | justify-content: flex-start; 152 | } 153 | 154 | .title { 155 | grid-area: title; 156 | display: grid; 157 | justify-content: flex-start; 158 | align-content: center; 159 | padding: 0 20px; 160 | } 161 | 162 | h2 { 163 | display: inline-block; 164 | padding: 0; 165 | color: var(--main-text-color); 166 | } 167 | 168 | .header-left { 169 | grid-area: left-section; 170 | display: grid; 171 | grid-auto-columns: auto; 172 | grid-auto-flow: column; 173 | justify-content: flex-start; 174 | align-content: flex-start; 175 | position: relative; 176 | padding: 15px 20px; 177 | } 178 | 179 | /* ----------- BIRTH CHART INPUT ------------ */ 180 | #chart-input-container { 181 | display: none; 182 | grid-template-columns: 1fr auto; 183 | grid-column-gap: 20px; 184 | align-content: flex-start; 185 | position: absolute; 186 | left: 20px; 187 | top: 30px; 188 | z-index: 10; 189 | } 190 | 191 | #chart-inputs { 192 | display: grid; 193 | justify-content: flex-end; 194 | grid-row-gap: 8px; 195 | } 196 | 197 | #chart-btn { 198 | margin-top: 5px; 199 | } 200 | 201 | .app-btn { 202 | font-family: var(--main-font); 203 | color: var(--main-text-color); 204 | border: 1px solid var(--main-text-color); 205 | font-size: 14px; 206 | padding: 12px 30px; 207 | letter-spacing: 0.03em; 208 | } 209 | 210 | input { 211 | background-color: transparent; 212 | border: none; 213 | border: 1px solid var(--main-text-color); 214 | color: var(--main-text-color); 215 | padding: 4px 4px; 216 | font-family: var(--main-font); 217 | border-radius: 0; 218 | font-size: 14px; 219 | } 220 | 221 | .loc-container { 222 | display: grid; 223 | grid-auto-rows: auto; 224 | position: relative; 225 | } 226 | 227 | #place-input { 228 | display: block; 229 | padding: 4px 4px; 230 | font-family: var(--main-font); 231 | border-radius: 0; 232 | border: none; 233 | border: 1px solid var(--grey); 234 | font-size: 14px; 235 | } 236 | 237 | #loc-results { 238 | display: none; 239 | position: absolute; 240 | height: 0; 241 | top: 20px; 242 | overflow-y: scroll; 243 | border: 1px solid var(--grey); 244 | } 245 | 246 | #loc-results span { 247 | display: block; 248 | background-color: #f9f9f9; 249 | color: #222; 250 | font-size: 13px; 251 | padding: 4px; 252 | margin: 0; 253 | cursor: pointer; 254 | } 255 | 256 | #loc-results span:hover { 257 | background-color: #eee; 258 | } 259 | 260 | 261 | /* -------------- PLANET OUTPUT -------------- */ 262 | .content { 263 | display: grid; 264 | grid-template-areas: "planets chart chart-details"; 265 | height: 100%; 266 | max-height: 100%; 267 | } 268 | 269 | /* svg */ 270 | svg#responsive-svg { 271 | grid-area: chart; 272 | width: 100%; 273 | height: 100%; 274 | max-height: 100%; 275 | position: absolute; 276 | left: 0; 277 | top: 0; 278 | z-index: -1; 279 | } 280 | 281 | #planet-output { 282 | grid-area: planets; 283 | display: grid; 284 | justify-content: flex-start; 285 | align-content: flex-start; 286 | grid-template-rows: repeat(auto-fill, minmax(auto, 1fr)); 287 | grid-row-gap: 20px; 288 | padding: 0px 0px 10px 20px; 289 | position: relative; 290 | } 291 | 292 | .planet { 293 | display: grid; 294 | grid-auto-rows: auto; 295 | align-content: flex-start; 296 | font-size: 16px; 297 | line-height: 26px; 298 | } 299 | 300 | .planet span { 301 | display: block; 302 | } 303 | 304 | #planet-output-sm { 305 | display: grid; 306 | justify-content: flex-start; 307 | grid-auto-columns: auto; 308 | grid-template-rows: repeat(auto-fill, minmax(auto, 1fr)); 309 | grid-row-gap: 5px; 310 | padding: 10px 0px 10px 10px; 311 | position: relative; 312 | } 313 | 314 | /* ------ CHART DETAILS ------- */ 315 | #chart-details-container { 316 | grid-area: chart-details; 317 | display: grid; 318 | position: absolute; 319 | grid-row-gap: 25px; 320 | right: 20px; 321 | top: 20px; 322 | justify-content: flex-end; 323 | } 324 | 325 | #chart-details { 326 | display: grid; 327 | grid-row-gap: 25px; 328 | justify-content: flex-end; 329 | } 330 | 331 | .details-title { 332 | display: block; 333 | color: var(--lightgrey); 334 | font-size: 15px; 335 | margin-bottom: 5px; 336 | } 337 | 338 | .details-row-container { 339 | display: grid; 340 | grid-auto-rows: auto; 341 | align-content: flex-start; 342 | padding: 10px; 343 | grid-row-gap: 5px; 344 | border: 1px solid var(--grey); 345 | height: 1fr; 346 | max-height: 1fr; 347 | overflow-y: scroll; 348 | } 349 | 350 | .row { 351 | display: grid; 352 | grid-template-columns: 1fr 1fr 1fr; 353 | grid-column-gap: 15px; 354 | color: var(--grey); 355 | font-size: 15px; 356 | } 357 | 358 | .row > span { 359 | display: inline-block; 360 | } 361 | 362 | 363 | button { 364 | border: none; 365 | background: transparent; 366 | outline: none; 367 | cursor: pointer; 368 | padding: 0; 369 | } 370 | 371 | #menu { 372 | display: none; 373 | } 374 | 375 | #menu-icon { 376 | margin-top: 10px; 377 | } 378 | 379 | .line { 380 | fill: none; 381 | stroke: var(--main-text-color); 382 | stroke-width: 6px; 383 | stroke-linecap: round; 384 | stroke-linejoin: round; 385 | } 386 | 387 | .line.top, 388 | .line.bottom { 389 | stroke-dasharray: 50px 600px; 390 | stroke-dashoffset: 0px; 391 | } 392 | 393 | .line.cross { 394 | stroke-dasharray: 50px 60px; 395 | stroke-dashoffset: 0px; 396 | } 397 | 398 | #sidenav { 399 | display: grid; 400 | grid-template-columns: 1fr; 401 | position: fixed; 402 | top: 0; 403 | bottom: 0; 404 | height: 100%; 405 | width: 200px; 406 | left: -200px; 407 | z-index: 30; 408 | background-color: var(--sidenav-bg); 409 | padding: 10px; 410 | font-size: 16px; 411 | line-height: 26px; 412 | color: var(--sidenav-text); 413 | } 414 | 415 | #close-btn { 416 | position: absolute; 417 | right: 15px; 418 | top: 10px; 419 | cursor: pointer; 420 | font-size: 30px; 421 | z-index: 11; 422 | } 423 | 424 | #close-btn::before { 425 | content: "\00d7"; 426 | } 427 | 428 | 429 | @media screen and (max-width: 1250px) { 430 | #planet-output > div { 431 | font-size: 14px; 432 | line-height: 20px; 433 | } 434 | } 435 | 436 | @media screen and (max-width: 950px) { 437 | .header { 438 | grid-template-areas: "menu title left-section"; 439 | grid-template-columns: 1fr auto 1fr; 440 | } 441 | 442 | .header-left { 443 | justify-content: center; 444 | } 445 | 446 | #menu { 447 | grid-area: menu; 448 | display: inline-block; 449 | cursor: pointer; 450 | } 451 | 452 | .title { 453 | justify-content: center; 454 | margin: 0 auto; 455 | } 456 | 457 | svg#responsive-svg { 458 | width: 100%; 459 | height: 100%; 460 | max-height: 100%; 461 | left: 0; 462 | top: 10px; 463 | } 464 | 465 | #chart-inputs { 466 | right: 30px; 467 | top: 20px; 468 | } 469 | 470 | #chart-details-container { 471 | display: none; 472 | } 473 | 474 | .houses-container { 475 | display: grid; 476 | grid-auto-rows: auto; 477 | align-content: flex-start; 478 | padding: 10px; 479 | border: none; 480 | } 481 | 482 | .row { 483 | display: grid; 484 | grid-template-columns: auto; 485 | grid-auto-rows: auto; 486 | margin-bottom: 5px; 487 | } 488 | 489 | .row > span { 490 | display: block; 491 | } 492 | } 493 | 494 | @media screen and (max-width: 550px) { 495 | .header { 496 | grid-template-areas: "menu title ." 497 | "left-section left-section left-section"; 498 | } 499 | 500 | #chart-inputs { 501 | justify-content: center; 502 | position: relative; 503 | grid-template-columns: 1fr 1fr; 504 | grid-template-areas: "date time" 505 | "place place" 506 | "button button"; 507 | align-content: flex-start; 508 | grid-column-gap: 10px; 509 | grid-row-gap: 10px; 510 | padding: 0 10px; 511 | left: 0; 512 | top:0; 513 | } 514 | 515 | #date-input { 516 | grid-area: date 517 | } 518 | 519 | #time-input { 520 | grid-area: time 521 | } 522 | 523 | .loc-container { 524 | grid-area: place 525 | } 526 | 527 | #chart-btn { 528 | grid-area: button; 529 | margin-top: 0px; 530 | } 531 | 532 | svg#responsive-svg { 533 | top: 20px; 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /app/static/src/birthchart.js: -------------------------------------------------------------------------------- 1 | import cityTimezones from 'city-timezones' 2 | import { requestData, clear } from './main.js' 3 | const ChartInputs = document.getElementById('chart-input-container') 4 | const InputCloseBtn = document.getElementById('input-close-btn') 5 | const NewChartBtn = document.getElementById('new-chart') 6 | const ChartBtn = document.getElementById('chart-btn') 7 | const DateInput = document.getElementById('date-input') 8 | const TimeInput = document.getElementById('time-input') 9 | const LocationInput = document.getElementById('place-input') 10 | const SearchDropdown = document.getElementById('loc-results') 11 | 12 | var timeout 13 | var dateVal 14 | var timeVal 15 | var locationVal = null 16 | 17 | NewChartBtn.addEventListener('click', () => { 18 | ChartInputs.style.display = 'grid' 19 | NewChartBtn.style.display = 'none' 20 | }) 21 | 22 | InputCloseBtn.addEventListener('click', () => { 23 | ChartInputs.style.display = 'none' 24 | NewChartBtn.style.display = 'block' 25 | }) 26 | 27 | setDateTime() 28 | 29 | function setDateTime() { 30 | let today = new Date() 31 | let date = today.toISOString().substring(0, 10) 32 | let time = today.toTimeString().substring(0, 5) 33 | 34 | DateInput.value = date 35 | TimeInput.value = time 36 | 37 | dateVal = date 38 | timeVal = time 39 | } 40 | 41 | ChartBtn.addEventListener('click', handleChart) 42 | DateInput.addEventListener('change', (e) => { 43 | dateVal = e.target.value 44 | }) 45 | TimeInput.addEventListener('change', (e) => { 46 | timeVal = e.target.value 47 | }) 48 | LocationInput.addEventListener('input', handleInput) 49 | 50 | function handleChart() { 51 | if (dateVal && timeVal && locationVal) { 52 | return requestBirthChart(dateVal, timeVal, locationVal) 53 | } else { 54 | alert('Please fill out all fields') 55 | } 56 | } 57 | 58 | function requestBirthChart(dateVal, timeVal, locationVal) { 59 | try { 60 | clear() 61 | const { lat, lng, timezone } = locationVal 62 | const str = buildDateString(dateVal, timeVal, timezone) 63 | const payload = JSON.stringify({ long: lng, lat: lat, date: str }) 64 | requestData(payload) 65 | } catch(err) { 66 | console.log('requestBirthChart err', err) 67 | throw err 68 | } 69 | } 70 | 71 | function handleInput(e) { 72 | SearchDropdown.style.height = 0 73 | if (e.isComposing) return 74 | const data = e.target.value 75 | if (timeout) { 76 | clearTimeout(timeout) 77 | } 78 | // add debounce to search 79 | timeout = setTimeout(() => { 80 | showResults(data) 81 | }, 300) 82 | } 83 | 84 | function showResults(data) { 85 | let results = cityTimezones.findFromCityStateProvince(data) 86 | 87 | if (results && results.length > 0) { 88 | let resultsInUS = results.filter(r => r.iso2 === 'US') 89 | let rest = results.filter(r => r.iso2 !== 'US') 90 | results = [...resultsInUS, ...rest] 91 | 92 | // construct dynamic search dropdown with scrollable results 93 | SearchDropdown.style.display = 'block' 94 | SearchDropdown.style.height = 'auto' 95 | SearchDropdown.style.width = "100%" 96 | SearchDropdown.style.maxHeight = '175px' 97 | SearchDropdown.innerHTML = `${results.map(r => { 98 | let text = r.iso2 === 'US' 99 | ? `${r.city}, ${r.state_ansi}` 100 | : `${r.city}, ${r.country}` 101 | // embed search result data in element dataset attributes 102 | return (`${text}`) 103 | }).join('')}` 104 | // attach event listeners to individual result elements to capture user selection 105 | const resultEls = document.querySelectorAll('.result') 106 | for (const el of resultEls) { 107 | el.addEventListener('click', (e) => { 108 | getResult(el) 109 | }) 110 | } 111 | } 112 | } 113 | 114 | function getResult(el) { 115 | const result = { 116 | lat: Number(el.dataset.lat), 117 | lng: Number(el.dataset.lng), 118 | timezone: el.dataset.tmz 119 | } 120 | locationVal = result 121 | // show selection in input field 122 | LocationInput.value = el.innerText 123 | // finally, hide dropdown 124 | SearchDropdown.replaceChildren() 125 | SearchDropdown.style.height = 0 126 | SearchDropdown.style.display = 'none' 127 | } 128 | 129 | // Output: string containing ISO-formatted date + UTC time (w/ seconds), no timezone. 130 | function buildDateString(dateValue, time, timezone) { 131 | // Create a date to pass to Intl.DateTimeFormat 132 | let str = fixForSafari(`${dateValue} ${time}`) 133 | 134 | // Obtain short-form timezone code from location input 135 | const tmz = new Intl.DateTimeFormat('en-US', { 136 | timeZone: timezone, 137 | timeZoneName: 'short' 138 | }).format(new Date(str)).split(', ')[1] 139 | 140 | // This is essentially a workaround for how bonkers javascript's Date is: 141 | // Construct new date object from string containing the correct timezone code. 142 | // When the output inevitably gets converted into the client's local timezone, 143 | // it will maintain its accuracy when converted into a UTC string. 144 | const withTmz = fixForSafari(`${dateValue} ${time}:00 ${tmz}`) 145 | const dateWithTmz = new Date(withTmz) 146 | 147 | let utcStr = dateWithTmz.toUTCString().substr(-12) 148 | utcStr = utcStr.substr(0, 8) 149 | const dateString = `${dateValue} ${utcStr}` 150 | return dateString 151 | } 152 | 153 | function fixForSafari(val) { 154 | // Safari's rendering engine doesn't accept hyphen-separated date strings. 155 | // On iOS, *every* mobile browser uses this rendering engine, so here we are. 156 | let v = val.replace(/-/g, "/") 157 | return v 158 | } 159 | 160 | export function getCurrentDateString() { 161 | const date = new Date() 162 | const isoStr = date.toISOString().substr(0, 10) 163 | let utcStr = date.toUTCString().substr(-12) 164 | utcStr = utcStr.substr(0, 8) 165 | const dateString = `${isoStr} ${utcStr}` 166 | return dateString 167 | } 168 | -------------------------------------------------------------------------------- /app/static/src/darkmode.js: -------------------------------------------------------------------------------- 1 | const Mode = document.getElementById('mode') 2 | const Body = document.querySelector('body') 3 | 4 | var localDarkMode = localStorage.getItem('darkMode') 5 | var darkMode = false 6 | 7 | if (localDarkMode !== null) { 8 | darkMode = JSON.parse(localDarkMode) 9 | setCurrentTheme(darkMode) 10 | } 11 | 12 | Mode.addEventListener('click', (e) => { 13 | darkMode = !darkMode 14 | setCurrentTheme(darkMode) 15 | localStorage.setItem('darkMode', JSON.stringify(darkMode)) 16 | }) 17 | 18 | function setCurrentTheme(isDarkMode) { 19 | if (isDarkMode === true) { 20 | Body.setAttribute('class', `theme-dark`) 21 | } else { 22 | if (Body.hasAttribute('class')) Body.removeAttribute('class') 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/static/src/main.js: -------------------------------------------------------------------------------- 1 | import './darkmode.js' 2 | import SvgCanvas from './svg.js' 3 | import { getCurrentDateString } from './birthchart.js' 4 | const Sidenav = createSidenav() 5 | const PlanetOutputLg = document.getElementById('planet-output') 6 | const PlanetOutputSm = document.getElementById('planet-output-sm') 7 | const planets = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto'] 8 | const glyphs = ['☉', '☽', '☿', '♀', '♂', '♃', '♄', '♅', '♆', '♇'] 9 | const baseUrl = window.location.host.includes('astro-clock.com') 10 | ? 'wss://astro-clock.com' 11 | : 'ws://127.0.0.1:8000' 12 | 13 | const minWidthSmall = 950 14 | var planetOutput = PlanetOutputLg 15 | var interval 16 | var socket 17 | var loaded = false 18 | var results = [] 19 | var birthChartMode = false 20 | 21 | window.addEventListener('DOMContentLoaded', () => { 22 | if (window.innerWidth <= minWidthSmall) planetOutput = PlanetOutputSm 23 | SvgCanvas.drawChart() 24 | initSocket() 25 | }) 26 | 27 | window.addEventListener('resize', () => { 28 | if (window.innerWidth <= minWidthSmall && planetOutput !== PlanetOutputSm) { 29 | resetOutput(PlanetOutputSm) 30 | } else if (window.innerWidth > minWidthSmall && planetOutput !== PlanetOutputLg) { 31 | resetOutput(PlanetOutputLg) 32 | if (Sidenav.navOpen) Sidenav.closeNav() 33 | } 34 | if (birthChartMode) { 35 | return processPlanetData(results) 36 | } 37 | if (results.length > 0) SvgCanvas.drawPlanets(results) 38 | }) 39 | 40 | export function clear() { 41 | if (!birthChartMode) { 42 | birthChartMode = true 43 | } 44 | if (interval) clearInterval(interval) 45 | planetOutput.innerHTML = '' 46 | loaded = false 47 | } 48 | 49 | function resetOutput(outputEl) { 50 | planetOutput.innerHTML = '' 51 | planetOutput = outputEl 52 | loaded = false 53 | } 54 | 55 | function initSocket() { 56 | try { 57 | const client_id = Date.now() 58 | socket = new WebSocket(`${baseUrl}/ws/${client_id}`) 59 | socket.addEventListener('open', () => { 60 | console.log('connected') 61 | requestData() 62 | }) 63 | socket.addEventListener('message', (e) => { 64 | handleMessage(e.data) 65 | }) 66 | socket.addEventListener('close', () => { 67 | console.log('disconnected') 68 | listenStop() 69 | }) 70 | listenStart() 71 | } catch(err) { 72 | throw err 73 | } 74 | } 75 | 76 | export function requestData(payload = null) { 77 | try { 78 | if (!payload) payload = JSON.stringify({ date: getCurrentDateString() }) 79 | if (socket?.readyState === 1) { 80 | socket.send(payload) 81 | } 82 | } catch(err) { 83 | console.log('requestData err', err) 84 | throw err 85 | } 86 | } 87 | 88 | function handleMessage(data) { 89 | data = JSON.parse(data) 90 | if (data.error) { 91 | console.log('Received error: ', data.exc_value) 92 | console.log(data.traceback) 93 | listenStop() 94 | return 95 | } 96 | return processPlanetData(data) 97 | } 98 | 99 | function processPlanetData(latest) { 100 | try { 101 | if (!birthChartMode && JSON.stringify(latest) === JSON.stringify(results)) return 102 | for (const result of latest) { 103 | updatePlanetOutput(result) 104 | } 105 | SvgCanvas.drawPlanets(latest) 106 | results = latest 107 | if (!loaded) loaded = true 108 | } catch (err) { 109 | console.log('processPlanetData err: ', err) 110 | throw err 111 | listenStop() 112 | } 113 | } 114 | 115 | function updatePlanetOutput(result) { 116 | const keys = ['name', 'position'] 117 | const previous = results.find(r => r.id === result?.id) 118 | if (!birthChartMode && loaded && previous) { 119 | if (JSON.stringify(result) === JSON.stringify(previous)) return 120 | } 121 | const str = buildStr(result, keys) 122 | if (!loaded) { 123 | const div = document.createElement('div') 124 | div.setAttribute('id', result.name) 125 | div.setAttribute('class', 'planet') 126 | div.innerHTML = str 127 | planetOutput.appendChild(div) 128 | } else { 129 | const el = document.getElementById(`${result.name}`) 130 | el.innerHTML = str 131 | } 132 | } 133 | 134 | function buildStr(result, keys) { 135 | const str = `${keys.map((k, i) => { 136 | let content = `${result[k]}` 137 | if (i === 0 && result.hasOwnProperty('id')) { 138 | content = `${result[k]} ${glyphs[result.id]}` 139 | } 140 | return content 141 | }).join('')}` 142 | return str 143 | } 144 | 145 | function listenStart() { 146 | interval = setInterval(requestData, 900) 147 | console.log('listen started') 148 | } 149 | 150 | function listenStop() { 151 | clearInterval(interval) 152 | const states = [1, 0] 153 | if (socket && states.includes(socket.readyState)) { 154 | console.log('closing socket') 155 | socket.close() 156 | } 157 | console.log('listen stopped') 158 | } 159 | 160 | function createSidenav() { 161 | class Sidenav { 162 | nav = document.getElementById('sidenav') 163 | menuBtn = document.getElementById('menu') 164 | closeBtn = document.getElementById('close-btn') 165 | constructor() { 166 | this.navOpen = false 167 | this.menuBtn.addEventListener('click', () => { this.openNav() }) 168 | this.closeBtn.addEventListener('click', () => { this.closeNav() }) 169 | } 170 | openNav() { 171 | this.nav.style.left = 0 172 | this.navOpen = true 173 | } 174 | closeNav() { 175 | this.nav.style.left = '-200px' 176 | this.navOpen = false 177 | } 178 | } 179 | return new Sidenav() 180 | } 181 | -------------------------------------------------------------------------------- /app/static/src/svg.js: -------------------------------------------------------------------------------- 1 | const svg = document.getElementById('responsive-svg') 2 | const ChartEl = document.getElementById('chart-svg') 3 | const ChartDetails = document.getElementById('chart-details') 4 | const svgns = "http://www.w3.org/2000/svg" 5 | const signs = ['Aries', 'Taurus', 'Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] 6 | const signGlyphs = ['♈︎', '♉︎', '♊︎', '♋︎', '♌︎', '♍︎', '♎︎', '♏︎', '♐︎', '♑︎', '♒︎', '♓︎'] 7 | const minWidthSmall = 950 8 | const font = '14px Menlo' 9 | const majorAspects = [ 10 | { name: 'Conjunct', degrees: 0 }, 11 | { name: 'Sextile', degrees: 60 }, 12 | { name: 'Square', degrees: 90 }, 13 | { name: 'Trine', degrees: 120 }, 14 | { name: 'Opposite', degrees: 180 } 15 | ] 16 | 17 | export default class SvgCanvas { 18 | static signData = [] 19 | static planetsWithAspects = [] 20 | 21 | static drawPlanets(data) { 22 | const asc = data.find(r => r.name === 'Ascendant') 23 | this.drawChart(asc) 24 | this.planetsWithAspects = [] 25 | for (const planet of data) { 26 | const degrees = planet.deg 27 | const angle = this.calcPlanetAngle(planet, degrees) 28 | const label = (window.innerWidth <= minWidthSmall) ? planet.label_sm : planet.label 29 | this.drawPlanet(angle, label, degrees) 30 | this.updatePlanetAspects(planet, angle, data) 31 | } 32 | if (this.planetsWithAspects.length > 0) this.drawAspects() 33 | if (asc) this.drawHouses(asc) 34 | } 35 | 36 | static updatePlanetAspects(planet, angle, data) { 37 | const otherPlanets = data.filter(p => p.id !== planet.id) 38 | const orb = (planet.name === 'Sun' || planet.name === 'Moon') ? 10 : 6 39 | const planetAspects = otherPlanets.flatMap(planetB => { 40 | const degreesB = planetB.deg 41 | const angleB = this.calcPlanetAngle(planetB, degreesB) 42 | const diff = getPlanetDegreeDiff(angle, angleB) 43 | const planetAspects = calcAspects(diff, orb) 44 | if (planetAspects.length > 0) { 45 | return { planetB: planetB.name, angleB: angleB, ...planetAspects[0] } 46 | } 47 | return [] 48 | }) 49 | if (planetAspects.length > 0) { 50 | this.planetsWithAspects.push({ name: planet.name, angle, aspects: planetAspects }) 51 | } 52 | } 53 | 54 | static calcPlanetAngle(planet, degrees) { 55 | const planetSignData = this.signData.find(s => s.sign === planet.sign) 56 | const { startAngle } = planetSignData 57 | return (startAngle - degrees) 58 | } 59 | 60 | static drawPlanet(angle, label){ 61 | const { center_x, center_y, min, radius } = getClientDimensions() 62 | const innerDimensions = { center_x, center_y, radius: (radius * 0.8), min } 63 | let { x, y } = calcAngleCoords(angle, innerDimensions) 64 | const pointAttrs = { cy: y, cx: x, r: 4, id: 'planet-point' } 65 | let outerCoords = calcAngleCoords(angle) 66 | let textAttrs = { x: outerCoords.x, y: outerCoords.y } 67 | if (outerCoords.x < center_x) textAttrs.style = 'text-anchor: end' 68 | 69 | let dy = (outerCoords.y < center_y) ? -5 : 15 70 | let dx = (outerCoords.x === center_x) ? 0 : (outerCoords.x < center_x ? -5 : 10) 71 | 72 | const angleAbs = Math.abs(angle) 73 | if (angleAbs === 0 || angleAbs === 360) { 74 | dy = 0 75 | } else if (angleAbs === 180 || angleAbs === 270) { 76 | dx = 0 77 | } 78 | const tspanAttrs = { dy, dx, id: 'planet-text' } 79 | drawSVG('circle', { ...pointAttrs }) 80 | drawTextWithTspan(textAttrs, tspanAttrs, label) 81 | } 82 | 83 | static drawAspects() { 84 | const { center_x, center_y, min, radius, innerRadius } = getClientDimensions() 85 | const innerDimensions = { center_x, center_y, radius: innerRadius, min } 86 | const aspectsAdded = [] 87 | 88 | for (const planet of this.planetsWithAspects) { 89 | const planetA = calcAngleCoords(planet.angle, innerDimensions) 90 | 91 | for (const aspect of planet.aspects) { 92 | const reverse = buildRow(aspect.planetB, aspect.name, planet.name) 93 | if (aspectsAdded.includes(reverse)) { 94 | continue 95 | } 96 | const content = buildRow(planet.name, aspect.name, aspect.planetB) 97 | aspectsAdded.push(content) 98 | 99 | const planetB = calcAngleCoords(aspect.angleB, innerDimensions) 100 | drawSVG('path', { 101 | d: `M ${planetA.x},${planetA.y}, L ${planetB.x},${planetB.y}`, 102 | id: aspect.name 103 | }) 104 | } 105 | } 106 | displayAspectDetails() 107 | } 108 | 109 | static drawHouses(asc) { 110 | const houses = asc.houses 111 | const { center_x, center_y, min, radius, innerRadius } = getClientDimensions() 112 | const innerDimensions = { center_x, center_y, radius: innerRadius, min } 113 | const textDimensions = { center_x, center_y, radius: (radius * 0.3), min } 114 | const tspanAttrs = { id: 'house-text', 115 | 'dominant-baseline': 'central', 116 | 'alignment-baseline': 'middle' 117 | } 118 | let h, startAngle, endAngle, next; 119 | for (let i = 0; i < houses.length; i++) { 120 | h = houses[i] 121 | startAngle = this.calcPlanetAngle(h, h.deg) 122 | next = houses[i + 1] 123 | endAngle = (!next) ? 360 : this.calcPlanetAngle(next, next.deg) 124 | const innerCoords = calcAngleCoords(startAngle, innerDimensions) 125 | drawSVG('path', { 126 | d: `M ${center_x},${center_y}, L ${innerCoords.x},${innerCoords.y}`, 127 | id: 'house-path' 128 | }) 129 | const angleDiff = Math.abs(endAngle) - Math.abs(startAngle) 130 | const midAngle = startAngle - (angleDiff / 2) 131 | const textCoords = calcAngleCoords(midAngle, textDimensions) 132 | const textAttrs = { x: textCoords.x, y: textCoords.y, style: 'text-anchor: middle;' } 133 | drawTextWithTspan(textAttrs, tspanAttrs, h.label) 134 | } 135 | } 136 | 137 | static drawChart(asc = null) { 138 | const { center_x, center_y, min, radius, innerRadius } = getClientDimensions() 139 | ChartEl.replaceChildren() 140 | drawSVG('circle', { id: 'chart-circle', cy: center_y, cx: center_x, r: radius }) 141 | drawSVG('circle', { cy: center_y, cx: center_x, r: innerRadius }) 142 | this.drawSigns(asc, center_x, center_y, min, radius, innerRadius) 143 | } 144 | 145 | static drawSigns(asc = null, center_x, center_y, min, radius, innerRadius) { 146 | let index = 0 147 | let startAngle = 0 148 | let endAngle = 30 149 | let sign 150 | this.signData = [] 151 | 152 | if (asc) { 153 | const ascSign = signs.find(s => s === asc.sign) 154 | index = signs.indexOf(ascSign) 155 | const ascDeg = asc.deg 156 | startAngle = startAngle + ascDeg 157 | } 158 | 159 | while (true) { 160 | sign = signs[index] 161 | if (this.signData.length > 0 && asc && asc.sign === sign) { 162 | break 163 | } 164 | if (!sign) { 165 | if (!asc) break 166 | if (this.signData.length < signs.length) { 167 | index = 0 168 | sign = signs[index] 169 | } else { 170 | break 171 | } 172 | } 173 | if (this.signData.length > 0) { 174 | startAngle = startAngle - 30 175 | endAngle = endAngle - 30 176 | } 177 | drawSign(index, sign, startAngle, { center_x, center_y, min, radius, innerRadius }) 178 | this.signData.push({ id: index, sign, startAngle, endAngle }) 179 | index++ 180 | } 181 | } 182 | } 183 | 184 | function drawSign(index, sign, startAngle, { ...dimensions }) { 185 | const { center_x, center_y, min, radius, innerRadius } = dimensions 186 | const glyph = signGlyphs[index] 187 | let { x, y } = calcAngleCoords(startAngle) 188 | const innerData = { center_x, center_y, radius: innerRadius, min } 189 | const innerCoords = calcAngleCoords(startAngle, innerData) 190 | drawSVG('path', { id: 'sign-path', 191 | d: `M ${innerCoords.x},${innerCoords.y}, L ${x},${y}` 192 | }) 193 | const midAngle = startAngle - 15 194 | const textData = { center_x, center_y, radius: (radius * 0.9), min } 195 | const textCoords = calcAngleCoords(midAngle, textData) 196 | const textAttrs ={ x: textCoords.x, y: textCoords.y, style: 'text-anchor: middle;' } 197 | const tspanAttrs = { 198 | id: 'sign-text', 199 | 'dominant-baseline': 'central', 200 | 'alignment-baseline': 'middle' 201 | } 202 | drawTextWithTspan(textAttrs, tspanAttrs, glyph) 203 | } 204 | 205 | function calcAspects(angle, orb) { 206 | return majorAspects.filter(aspect => { 207 | const max = aspect.degrees + orb 208 | const min = aspect.degrees - orb 209 | if (angle <= max && angle >= min) { 210 | return aspect 211 | } 212 | }) 213 | } 214 | 215 | function getPlanetDegreeDiff(a, b) { 216 | a = Math.abs(a) 217 | b = Math.abs(b) 218 | a %= 360 219 | b %= 360 220 | if (a < b) return getPlanetDegreeDiff(b, a) 221 | else return Math.min(a - b, b - a + 360) 222 | } 223 | 224 | function calcAngleCoords(angle, data = null) { 225 | if (!data) data = getClientDimensions() 226 | const { center_x, center_y, min, radius } = data 227 | const radians = angle * (Math.PI / 180) 228 | 229 | let x = Math.round(center_x + (radius * Math.cos(-radians))) 230 | let y = Math.round(center_y + (radius * Math.sin(-radians))) 231 | 232 | let diff 233 | if (x > center_x) { 234 | diff = (x - center_x) * 2 235 | x = x - diff 236 | } else if (x < center_x) { 237 | diff = (center_x - x) * 2 238 | x = x + diff 239 | } 240 | return { x, y } 241 | } 242 | 243 | function getClientDimensions() { 244 | const center_x = svg.clientWidth / 2 245 | const center_y = svg.clientHeight / 2 246 | const min = Math.min(svg.clientHeight, svg.clientWidth) 247 | const radius = window.innerWidth <= minWidthSmall 248 | ? (min / 2) * 0.7 249 | : (min / 2) * 0.9 250 | const innerRadius = (radius * 0.8) 251 | return { center_x, center_y, min, radius, innerRadius } 252 | } 253 | 254 | function drawTextWithTspan(textAttrs, tspanAttrs, label) { 255 | const textEl = drawSVG('text', { ...textAttrs }, false) 256 | const tspan = drawSVG('tspan', { ...tspanAttrs }, false, label) 257 | textEl.append(tspan) 258 | ChartEl.appendChild(textEl) 259 | } 260 | 261 | function drawSVG(type, attrs, append = true, text = null) { 262 | const newEl = document.createElementNS(svgns, `${type}`) 263 | gsap.set(newEl, { attr: { ...attrs } }) 264 | if (text) newEl.append(text) 265 | if (!append) return newEl 266 | else ChartEl.appendChild(newEl) 267 | } 268 | 269 | function displayAspectDetails() { 270 | const planetsWithAspects = SvgCanvas.planetsWithAspects 271 | const aspectsAdded = [] 272 | const title = '// Aspects' 273 | const content = ` 274 | ${planetsWithAspects.map((p, i) => { 275 | const { aspects } = p 276 | return `${aspects.map(a => { 277 | const reverse = buildRow(a.planetB, a.name, p.name) 278 | if (aspectsAdded.includes(reverse)) return 279 | const content = buildRow(p.name, a.name, a.planetB) 280 | aspectsAdded.push(content) 281 | return content 282 | }).join('')}` 283 | }).join('')}` 284 | ChartDetails.innerHTML = buildDetails(title, content) 285 | } 286 | 287 | function buildRow(left, center, right) { 288 | return (` 289 |
290 | ${left} 291 | ${center} 292 | ${right} 293 |
294 | `) 295 | } 296 | 297 | function buildDetails(title, content) { 298 | return (`
299 | 300 | ${title} 301 | 302 |
303 | ${content} 304 |
305 |
`) 306 | } 307 | -------------------------------------------------------------------------------- /app/static/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: "development", 6 | entry: "./src/main.js", 7 | output: { 8 | path: path.resolve(__dirname, "./dist"), 9 | filename: "main.bundle.js" 10 | }, 11 | devServer: { 12 | port: 8080, 13 | hot: true 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.m?js$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: "babel-loader", 22 | options: { 23 | presets: ['@babel/preset-env'] 24 | } 25 | } 26 | }, 27 | { 28 | test: /\.css$/i, 29 | use: ['style-loader', 'css-loader'] 30 | } 31 | ] 32 | }, 33 | plugins: [ 34 | new HtmlWebpackPlugin({ 35 | template: path.resolve(__dirname, "./public/index.html") 36 | }) 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Astro Clock 10 | 11 | 12 | 16 |
17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |

Astro Clock

29 |
30 |
31 | 32 |
33 |
x
34 |
35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | 2 | glyphs = ['☉', '☽', '☿', '♀', '♂', '♃', '♄', '♅', '♆', '♇'] 3 | roman_nums = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'] 4 | 5 | def create_metaclass(base_class, subclass): 6 | method_dict = {f'{k}': v for k,v in subclass.__dict__.items() if callable(v) and v.__name__ != '__init__'} 7 | return type(f'_{subclass.__name__}', (base_class,), method_dict) 8 | 9 | 10 | def create_metaclasses(base_class, subclasses): 11 | classes = [create_metaclass(base_class, s) for s in subclasses] 12 | return classes 13 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ~/astro-clock 4 | unset GIT_DIR 5 | git pull 6 | source env/bin/activate 7 | pip3 install -r requirements.txt 8 | deactivate 9 | cd ~/ 10 | sudo -S systemctl restart astro-clock 11 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | 2 | run: 3 | uvicorn app.main:app --reload 4 | 5 | 6 | build: 7 | cd app/static && \ 8 | npm run build && \ 9 | cd ../../ 10 | 11 | 12 | serve: 13 | cd app/static && \ 14 | npm run serve 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==0.7.0 2 | aniso8601==7.0.0 3 | astropy==4.3.post1 4 | async-exit-stack==1.0.1 5 | async-generator==1.10 6 | cerridwen==1.4.1 7 | certifi==2021.5.30 8 | charset-normalizer==2.0.4 9 | click==7.1.2 10 | dnspython==2.1.0 11 | email-validator==1.1.3 12 | fastapi==0.68.0 13 | graphene==2.1.9 14 | graphql-core==2.3.2 15 | graphql-relay==2.0.1 16 | h11==0.12.0 17 | httptools==0.1.2 18 | idna==3.2 19 | itsdangerous==1.1.0 20 | Jinja2==3.0.1 21 | MarkupSafe==2.0.1 22 | numpy==1.21.1 23 | orjson==3.6.0 24 | promise==2.3 25 | pydantic==1.8.2 26 | pyerfa==2.0.0 27 | pyswisseph==2.8.0.post1 28 | python-dotenv==0.19.0 29 | python-multipart==0.0.5 30 | PyYAML==5.4.1 31 | requests==2.26.0 32 | Rx==1.6.1 33 | six==1.16.0 34 | starlette==0.14.2 35 | typing-extensions==3.10.0.0 36 | ujson==4.0.2 37 | urllib3==1.26.6 38 | uvicorn==0.13.4 39 | uvloop==0.15.3 40 | watchgod==0.7 41 | websockets==8.1 42 | --------------------------------------------------------------------------------