Source code for layup.dash_ui

import logging
from typing import Literal, Tuple, Optional, Any

import numpy as np

from layup.orbit_maths import ClassicalConic

[docs] PANEL = Literal["XY", "XZ", "YZ"]
[docs] PLANET_COLOURS_NIGHT = { "Mercury": "rgba(190,190,190,0.95)", # silver "Venus": "rgba(255,190,90,0.95)", # warm amber "Earth": "rgba(90,210,255,0.95)", # cyan "Mars": "rgba(255,90,90,0.95)", # red "Jupiter": "rgba(255,165,120,0.95)", # salmon/orange "Saturn": "rgba(255,230,150,0.95)", # pale gold "Uranus": "rgba(120,255,210,0.95)", # mint "Neptune": "rgba(185,120,255,0.95)", # purple }
[docs] PLANET_COLOURS_DAY = { "Mercury": "rgba(90,90,90,0.95)", # dark gray "Venus": "rgba(180,110,0,0.95)", # brown/orange "Earth": "rgba(0,140,190,0.95)", # teal-blue "Mars": "rgba(170,0,0,0.95)", # dark red "Jupiter": "rgba(170,90,60,0.95)", # brown "Saturn": "rgba(160,140,40,0.95)", # olive gold "Uranus": "rgba(0,150,110,0.95)", # green-teal "Neptune": "rgba(115,0,170,0.95)", # deep purple }
[docs] SPECIAL_COLOUR = { "night": "rgba(255,0,0,0.95)", # bright red "day": "rgba(200,80,0,0.95)", # dark orange }
[docs] ORBIT_COLOUR_DIM = { "night": {"3d": "rgba(144,167,209,0.05)", "2d": "rgba(144,167,209,0.20)"}, "day": {"3d": "rgba(30,60,120,0.55)", "2d": "rgba(30,60,120,0.15)"}, }
[docs] logger = logging.getLogger(__name__)
# --- set up plots ---
[docs] def add_reference_plane_xy(fig, lines: np.ndarray, planet_lines: np.ndarray, opacity: float = 0.10): """ Add the reference plane (either ecliptic or equatorial) to the 3D plot at Z=0 Parameters ----------- fig : object Plotly figure object lines : numpy float array Array of x,y,z coordinates of objects to be plotted lines : numpy float array Array of x,y,z coordinates of planets to be plotted opacity : float, optional (default = 0.10) Transparency of the reference plane """ import plotly.graph_objects as go # extract all x/y coords and flatten (ravel) to 1D x_obj = lines[..., 0].ravel() y_obj = lines[..., 1].ravel() # either take the extent of the plane to be the furthest orbit or furthest planet if planet_lines is not None and np.size(planet_lines) > 0: x_pla = planet_lines[..., 0].ravel() y_pla = planet_lines[..., 1].ravel() x0 = min(np.nanmin(x_obj), np.nanmin(x_pla)) x1 = max(np.nanmax(x_obj), np.nanmax(x_pla)) y0 = min(np.nanmin(y_obj), np.nanmin(y_pla)) y1 = max(np.nanmax(y_obj), np.nanmax(y_pla)) else: x0, x1 = np.nanmin(x_obj), np.nanmax(x_obj) y0, y1 = np.nanmin(y_obj), np.nanmax(y_obj) # add a bit of padding from the edges padx = 0.05 * (x1 - x0) if (x1 - x0) > 0 else 1.0 pady = 0.05 * (y1 - y0) if (y1 - y0) > 0 else 1.0 x0, x1 = x0 - padx, x1 + padx y0, y1 = y0 - pady, y1 + pady # now create the plane as nx x ny grid nx, ny = 40, 40 xs = np.linspace(x0, x1, nx) ys = np.linspace(y0, y1, ny) X, Y = np.meshgrid(xs, ys) Z = np.zeros_like(X) # add to the figure fig.add_trace( go.Surface( x=X, y=Y, z=Z, showscale=False, opacity=opacity, surfacecolor=np.zeros_like(X), hoverinfo="skip", name="ref-plane", hovertemplate=None, ) )
# --- very crude orbit classifier ---
[docs] def classify(a: float, e: float, Tj: float) -> str: """ Rough first pass orbit classifier based on input orbital elements. Does NOT do any orbit integrations to verify e.g. Trojan or other resonant behaviour, so use only as an approximate guess. Check your orbits thoroughly. Parameters ----------- a : float Semimajor axis of the object, in units of AU e : float Eccentricity of the object Tj : float Tisserand parameter with respect to Jupiter of the object """ q = a * (1 - e) # these are some very very crude dynamical classifiers for filtering # purposes, don't take them as gospel - do your own checks!! if e >= 1.0: return "Hyperbolic" elif np.isfinite(a) and a > 2000: return "Inner Oort Cloud" elif np.isfinite(Tj) and (Tj < 2.0): return "LPC" elif np.isfinite(Tj) and (2.0 <= Tj < 3.0) and (q < 5.2): return "JFC" elif np.isfinite(q) and (q < 1.3): return "NEO" elif np.isfinite(q) and (1.3 <= q < 1.66): return "Mars-Crosser" elif np.isfinite(a) and (2.0 <= a <= 3.5) and np.isfinite(q) and (q >= 1.66): return "MBA" elif np.isfinite(a) and (a < 30.04) and np.isfinite(q) and (q >= 7.35): return "Centaur" elif np.isfinite(a) and (30.04 <= a < 2000.0) and np.isfinite(q) and (q >= 7.35): return "TNO" else: return "Other"
# --- plot in 2D ---
[docs] def plotly_2D( lines: np.ndarray, canon: ClassicalConic, plot_sun: bool = True, sun_xyz: Optional[np.ndarray] = None, planet_lines: Optional[np.ndarray] = None, planet_id: Optional[np.ndarray] = None, return_fig: bool = False, output: Optional[str] = None, panel: Optional[PANEL] = None, panels: Optional[Tuple[PANEL, PANEL]] = None, special_lines: Optional[np.ndarray] = None, special_canon: Optional[ClassicalConic] = None, ): """ Create a 2D (1x2 subplot) interactive Plotly figure of orbits Parameters ----------- lines : dict of numpy array Dictionary of arrays with the orbit lines for each object in each plane+origin combination canon : ClassicalConic object Object with the conic section class instances of each object and their properties plot_sun : bool, optional (default = True) Flag to turn on/off plotting the Sun sun_xyz : dict of numpy array, optional (default = None) Dictionary of arrays with the Sun positions in each plane+origin combination planet_lines : dict of arrays, optional (default = None) Dictionary of arrays containing planet orbit lines for each planet in each plane+origin combination of shape (n_planets, n_points, 3) planet_id : numpy string array, optional (default = None) Array containing ID tags for each planet of shape (n_planets,) return_fig : bool, optional (default = False) Flag to turn on/off returning the figure object output : str, optional (default = None) String containing the html of the figure panel: str, optional (default = None) String containing which orientation to draw a single panel of. Must be one of "XY", "XZ", "YZ" panels: str, optional (default = None) String containing which orientation to draw two panels of. Must be one of "XY", "XZ", "YZ" special_lines: dict of arrays, option (default = None) Dictionary of arrays with the orbit lines for each special object in each plane+origin combination special_canon: ClassicalConic object (default = None) Object with the conic section class instances of each special object and their properties Returns -------- fig : object Plotly figure object """ import plotly.graph_objects as go from plotly.subplots import make_subplots def coords_for(p: PANEL, x: np.ndarray, y: np.ndarray, z: np.ndarray): if p == "XY": return ( x, y, "X [AU]", "Y [AU]", True, ) # <-- the final True here is letting later on know to plot this panel equal aspect, as top-down is onto the reference plane if p == "XZ": return ( x, z, "X [AU]", "Z [AU]", False, ) # <-- this and YZ are inclination driven so shouldn't be equal aspect if p == "YZ": return y, z, "Y [AU]", "Z [AU]", False logger.error(f"Unknown panel {p!r} (expected 'XY', 'XZ', 'YZ')") raise ValueError(f"Unknown panel {p!r} (expected 'XY', 'XZ', 'YZ')") # -- panel configuration -- if panels is not None: panels_to_show: Tuple[PANEL, ...] = tuple( panels ) # < -- if >1 panels, turn whichever options they are into tuple elif panel is not None: panels_to_show = (panel,) # < -- if 1 panel, turn that option into tuple else: panels_to_show = ("XY", "XZ") # < -- if none specified, default to 2 panel XY+XZ if len(panels_to_show) not in (1, 2): logger.error(f"Expected 1 or 2 panels, got {len(panels_to_show)}: {panels_to_show}") raise ValueError(f"Expected 1 or 2 panels, got {len(panels_to_show)}: {panels_to_show}") ncols = len(panels_to_show) fig = make_subplots(rows=1, cols=ncols, horizontal_spacing=0.10 if ncols == 2 else 0.02) # -- plot planets -- if planet_lines is not None: if planet_id is None: # in the case you have planet lines but no id, just assign them generic name tags planet_id = np.array([f"Planet {i}" for i in range(planet_lines.shape[0])], dtype="U32") for i in range(planet_lines.shape[0]): # get planet coords and colour by searching the global variable (default is night mode, # we can change that in the dash app and css later) x = planet_lines[i, :, 0] y = planet_lines[i, :, 1] z = planet_lines[i, :, 2] colour = PLANET_COLOURS_NIGHT.get(str(planet_id[i]), "rgba(200,200,200,0.6)") hover_text = f"{planet_id[i]}<extra></extra>" # loop over however many panels we have for col, p in enumerate(panels_to_show, start=1): xa, ya, _, _, _ = coords_for( p, x, y, z ) # <-- grab the correct axes x/y coords for whatever plane it is fig.add_trace( go.Scatter( x=xa, y=ya, mode="lines", line=dict(color=colour, width=2.2), hovertemplate=hover_text, showlegend=False, name=str(planet_id[i]), meta={"kind": "Planet"}, # <-- this tag is to prevent colours being overwritten later ), row=1, col=col, ) # -- plot input objects -- for i in range(lines.shape[0]): # get object coords x = lines[i, :, 0] y = lines[i, :, 1] z = lines[i, :, 2] # it may not be best practice to paste unicode symbols here but ¯\_(ツ)_/¯ # plotly was weird when i tried to do the direct unicode tag and render that hover_text = ( f"{canon.obj_id[i]}<br>" f"e: {canon.e[i]:.4f}<br>" f"i: {np.rad2deg(canon.inc[i]):.2f}°<br>" f"Ω: {np.rad2deg(canon.node[i]):.2f}°<br>" f"ω: {np.rad2deg(canon.argp[i]):.2f}°" ) # these are being extracted so we can crudely classify them later L = float(canon.L[i]) e = float(canon.e[i]) inc = float(canon.inc[i]) if abs(1 - e**2) < 1e-12: # <-- protect against parabolic orbits a = np.inf else: a = L / (1 - e**2) if (not np.isfinite(a)) or (a == 0.0): # <-- same again Tj = np.nan else: Tj = (5.2044 / a) + 2.0 * np.cos(inc) * np.sqrt((a / 5.2044) * (1 - e**2)) pop = classify(a, e, Tj) # loop over however many panels we have for col, p in enumerate(panels_to_show, start=1): xa, ya, _, _, _ = coords_for( p, x, y, z ) # <-- grab the correct axes x/y coords for whatever plane it is fig.add_trace( go.Scatter( x=xa, y=ya, mode="lines", line=dict(color="rgba(144,167,209,0.7)", width=1.5), hovertemplate=hover_text + "<extra></extra>", showlegend=False, name=str(canon.obj_id[i]), meta={"kind": pop}, ), row=1, col=col, ) # -- plot special orbits (on top of regular orbits) -- if special_lines is not None and special_canon is not None: for i in range(special_lines.shape[0]): x = special_lines[i, :, 0] y = special_lines[i, :, 1] z = special_lines[i, :, 2] hover_text = ( f"{special_canon.obj_id[i]}<br>" f"e: {special_canon.e[i]:.4f}<br>" f"i: {np.rad2deg(special_canon.inc[i]):.2f}°<br>" f"Ω: {np.rad2deg(special_canon.node[i]):.2f}°<br>" f"ω: {np.rad2deg(special_canon.argp[i]):.2f}°" ) for col, p in enumerate(panels_to_show, start=1): xa, ya, _, _, _ = coords_for(p, x, y, z) fig.add_trace( go.Scatter( x=xa, y=ya, mode="lines", line=dict(color=SPECIAL_COLOUR["night"], width=2.0), hovertemplate=hover_text + "<extra></extra>", showlegend=False, name=str(special_canon.obj_id[i]), meta={"kind": "Special"}, ), row=1, col=col, ) # -- plot the sun -- if plot_sun: if sun_xyz is None: sx, sy, sz = 0.0, 0.0, 0.0 else: sx, sy, sz = float(sun_xyz[0]), float(sun_xyz[1]), float(sun_xyz[2]) # loop over however many panels we have for col, p in enumerate(panels_to_show, start=1): xa, ya, _, _, _ = coords_for( p, np.array([sx]), np.array([sy]), np.array([sz]) ) # <-- grab the correct axes x/y coords for whatever plane it is fig.add_trace( go.Scatter( x=xa, y=ya, mode="markers", marker=dict(size=10, color="yellow"), showlegend=False, name="Sun", ), row=1, col=col, ) # -- pretty up the figure -- fig.update_layout( plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", font=dict(color="white"), autosize=True, margin=dict(l=60, r=60, t=40, b=60), hoverdistance=0, hovermode="closest", ) fig.update_xaxes(showgrid=True, gridcolor="rgba(255,255,255,0.08)") fig.update_yaxes(showgrid=True, gridcolor="rgba(255,255,255,0.08)") # axis titles + equal aspect for any XY panel # loop over however many panels we have for col, p in enumerate(panels_to_show, start=1): _, _, xtitle, ytitle, want_equal = coords_for( p, np.array([0.0]), np.array([0.0]), np.array([0.0]) ) # <-- grab the correct axes x/y titles for whatever plane it is fig.update_xaxes(title_text=xtitle, row=1, col=col) fig.update_yaxes(title_text=ytitle, row=1, col=col) if want_equal: if col == 1: fig.update_layout(yaxis=dict(scaleanchor="x", scaleratio=1)) elif col == 2: fig.update_layout(yaxis2=dict(scaleanchor="x2", scaleratio=1)) # return figure object if user wants if return_fig: return fig # otherwise write out and show if output: fig.write_html(output) fig.show() fig.show()
# --- plot in 3D ---
[docs] def plotly_3D( lines: np.ndarray, canon: ClassicalConic, plot_sun: bool = True, show_plane: bool = True, planet_lines: Optional[np.ndarray] = None, planet_id: Optional[np.ndarray] = None, sun_xyz: Optional[np.ndarray] = None, return_fig: bool = False, output: Optional[str] = None, special_lines: Optional[np.ndarray] = None, special_canon: Optional[ClassicalConic] = None, ): """ Create a 3D interactive Plotly figure of orbits Parameters ----------- lines : dict of numpy array Dictionary of arrays with the orbit lines for each object in each plane+origin combination canon : dict of objects Dictionary with the conic section class instances of each object and their properties plot_sun : bool, optional (default = True) Flag to turn on/off plotting the Sun show_plane : bool, optional (default = True) Flag to turn on/off plotting the reference plane (ecliptic or equatorial) planet_lines : dict of arrays, optional (default = None) Dictionary of arrays containing planet orbit lines for each planet in each plane+origin combination of shape (n_planets, n_points, 3) planet_id : numpy string array, optional (default = None) Array containing ID tags for each planet of shape (n_planets,) sun_xyz : dict of numpy array, optional (default = None) Dictionary of arrays with the Sun positions in each plane+origin combination return_fig : bool, optional (default = False) Flag to turn on/off returning the figure object output : str, optional (default = None) String containing the html of the figure special_lines: dict of arrays, option (default = None) Dictionary of arrays with the orbit lines for each special object in each plane+origin combination special_canon: ClassicalConic object (default = None) Object with the conic section class instances of each special object and their properties Returns -------- fig : object Plotly figure object """ import plotly.graph_objects as go fig = go.Figure() # -- plot reference plane -- if show_plane: add_reference_plane_xy(fig, lines, planet_lines, opacity=0.50) # -- plot planets -- if planet_lines is not None: if planet_id is None: planet_id = np.array([f"Planet {i}" for i in range(planet_lines.shape[0])], dtype="U32") for i in range(planet_lines.shape[0]): # get planet coords and colour by searching the global variable (default is night mode, # we can change that in the dash app and css later) x = planet_lines[i, :, 0] y = planet_lines[i, :, 1] z = planet_lines[i, :, 2] colour = PLANET_COLOURS_NIGHT.get(str(planet_id[i]), "rgba(200,200,200,0.6)") fig.add_trace( go.Scatter3d( x=x, y=y, z=z, mode="lines", line=dict(color=colour, width=5), hovertemplate=f"{planet_id[i]}<extra></extra>", showlegend=False, name=str(planet_id[i]), meta={"kind": "Planet"}, # <-- this tag is to prevent colours being overwritten later ) ) # -- plot input objects -- for i in range(lines.shape[0]): x = lines[i, :, 0] y = lines[i, :, 1] z = lines[i, :, 2] # it may not be best practice to paste unicode symbols here but ¯\_(ツ)_/¯ # plotly was weird when i tried to do the direct unicode tag and render that hover_text = ( f"{canon.obj_id[i]}<br>" f"e: {canon.e[i]:.4f}<br>" f"i: {np.rad2deg(canon.inc[i]):.2f}°<br>" f"Ω: {np.rad2deg(canon.node[i]):.2f}°<br>" f"ω: {np.rad2deg(canon.argp[i]):.2f}°" ) # these are being extracted so we can crudely classify them later L = float(canon.L[i]) e = float(canon.e[i]) inc = float(canon.inc[i]) if abs(1 - e**2) < 1e-12: # <-- protect against parabolic orbits a = np.inf else: a = L / (1 - e**2) if (not np.isfinite(a)) or (a == 0.0): # <-- same again Tj = np.nan else: Tj = (5.2044 / a) + 2.0 * np.cos(inc) * np.sqrt((a / 5.2044) * (1 - e**2)) pop = classify(a, e, Tj) fig.add_trace( go.Scatter3d( x=x, y=y, z=z, mode="lines", line=dict(color="rgba(144, 167, 209, 0.7)", width=3), hovertemplate=hover_text + "<extra></extra>", showlegend=False, name=str(canon.obj_id[i]), meta={"kind": pop}, ) ) # -- plot special orbits (on top of regular orbits) -- if special_lines is not None and special_canon is not None: for i in range(special_lines.shape[0]): x = special_lines[i, :, 0] y = special_lines[i, :, 1] z = special_lines[i, :, 2] hover_text = ( f"{special_canon.obj_id[i]}<br>" f"e: {special_canon.e[i]:.4f}<br>" f"i: {np.rad2deg(special_canon.inc[i]):.2f}°<br>" f"Ω: {np.rad2deg(special_canon.node[i]):.2f}°<br>" f"ω: {np.rad2deg(special_canon.argp[i]):.2f}°" ) fig.add_trace( go.Scatter3d( x=x, y=y, z=z, mode="lines", line=dict(color=SPECIAL_COLOUR["night"], width=4), hovertemplate=hover_text + "<extra></extra>", showlegend=False, name=str(special_canon.obj_id[i]), meta={"kind": "Special"}, ) ) # -- plot the sun -- if plot_sun: if sun_xyz is None: sx, sy, sz = 0.0, 0.0, 0.0 else: sx, sy, sz = float(sun_xyz[0]), float(sun_xyz[1]), float(sun_xyz[2]) fig.add_trace( go.Scatter3d( x=[sx], y=[sy], z=[sz], mode="markers", marker=dict(size=6, color="yellow"), showlegend=False, hovertext="Sun", ) ) # -- pretty up the figure -- fig.update_layout( template=None, paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(color="white"), autosize=True, margin=dict(l=0, r=0, t=40, b=0), scene=dict( xaxis=dict( title="X [AU]", showbackground=False, gridcolor="rgba(255, 255, 255, 0.02)", zerolinecolor="rgba(255, 255, 255, 0.4)", ), yaxis=dict( title="Y [AU]", showbackground=False, gridcolor="rgba(255, 255, 255, 0.02)", zerolinecolor="rgba(255, 255, 255, 0.4)", ), zaxis=dict( title="Z [AU]", showbackground=False, gridcolor="rgba(255, 255, 255, 0.02)", zerolinecolor="rgba(255, 255, 255, 0.4)", ), aspectmode="data", camera=dict( center=dict( x=0, y=0, z=-0.25 ) # <-- for some reason default camera looks really low to me, negative z shifts scene up in the frame ), ), ) # return figure object if user wants if return_fig: return fig # otherwise write out and show if output: fig.write_html(output) fig.show() fig.show()
# --- make the dash app ---
[docs] def run_dash_app( fig2d_cache: dict[tuple[str, str], "object"], fig3d_cache: dict[tuple[str, str], "object"], special_ids: Optional[list[str]] = None, ): """ Create and """ import dash from dash import Dash, dcc, html, Input, Output, State, ctx import dash_daq as daq import dash_ag_grid as dag import re import copy import threading import webbrowser # set up app and (very optionally) link to latex stylesheet app = Dash( __name__, assets_folder="data", external_stylesheets=["https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"], ) # establsh day/night mode theming THEME = { "night": { "bg": "black", "fg": "white", "muted": "rgba(255,255,255,0.82)", "grid": "rgba(255,255,255,0.08)", "zero": "rgba(255,255,255,0.12)", "drawer_bg": "rgba(0,0,0,0.70)", "tab_bg": "rgba(0,0,0,0.55)", "border": "rgba(255,255,255,0.18)", }, "day": { "bg": "white", "fg": "#111111", "muted": "rgba(0,0,0,0.75)", "grid": "rgba(0,0,0,0.10)", "zero": "rgba(0,0,0,0.18)", "drawer_bg": "rgba(255,255,255,0.86)", "tab_bg": "rgba(255,255,255,0.75)", "border": "rgba(0,0,0,0.15)", }, } ORBIT_COLOUR = { "night": "rgba(144,167,209,0.3)", # very faint blue-grey "day": "rgba(30,60,120,0.3)", # very faint dark blue } # defaults night_default = True theme0 = THEME["night"] if night_default else THEME["day"] fg0 = theme0["fg"] origin_bary_default = False plane_equ_default = False view_3d_default = True opacity_default = 0.5 default_key = ("helio", "ecl") initial_fig = fig3d_cache.get(default_key) or next(iter(fig3d_cache.values())) # in order to make a selecter for the orbits, we build an inventory of all selectable objects def collect_inventory(figs: list[object]): planets: set[str] = set() objids: set[str] = set() kinds: dict[str, str] = {} for f in figs: for tr in getattr(f, "data", []) or []: meta = getattr(tr, "meta", None) name = str(getattr(tr, "name", "")) if not name or name == "ref-plane": continue if isinstance(meta, dict): k = meta.get("kind") # <-- finally the meta tags come in to play! if k == "Planet": planets.add(name) continue if ( getattr(tr, "type", None) in ("scatter", "scatter3d") and getattr(tr, "mode", None) == "lines" ): objids.add(name) kinds.setdefault(name, k) return sorted(planets), sorted(objids), kinds # get our list of selectable things (if for some reason the cache is partial or could be # broken, fall back to whatever the initial_fig is) inv_planets, inv_objids, orbit_kinds = collect_inventory(list(fig3d_cache.values())) if not inv_planets or not inv_objids: inv_planets, inv_objids, orbit_kinds = collect_inventory([initial_fig]) # these are our table inventory rows inventory_rows = [{"kind": "Planet", "name": p} for p in inv_planets] + [ {"kind": orbit_kinds.get(o, "Other"), "name": o} for o in inv_objids ] # set up title/label styling helpers for swapping day/night, frame, origin, and view modes def title_style(fg: str): return {"marginBottom": "10px", "fontSize": "20px", "color": fg, "opacity": 0.95} def sublabel_style(fg: str): return {"fontSize": "17px", "color": fg, "opacity": 0.90} # this creates the proper div environment for the toggle switches so the labels wrap well and look nice def labeled_toggle( left_label: str, toggle_id: str, right_label: str, value: bool, fg: str, width_pix: int = 220 ): return html.Div( [ html.Div( left_label, style={**sublabel_style(fg), "marginRight": "14px", "whiteSpace": "nowrap"}, className="toggle-label", ), daq.ToggleSwitch(id=toggle_id, value=value, size=70, color="#4cd964"), html.Div( right_label, style={**sublabel_style(fg), "marginLeft": "14px", "whiteSpace": "nowrap"}, className="toggle-label", ), ], style={"display": "flex", "alignItems": "center", "justifyContent": "center", "width": "100%"}, ) # set up a template consistent "block" for all controls to be built into def control_block(title: str, body, fg: str, width_px: int = 200): return html.Div( [ html.Div(title, style={**title_style(fg), "textAlign": "center", "width": "100%"}), html.Div(body, style={"display": "flex", "justifyContent": "center", "width": "100%"}), ], style={ "width": "100%", "minWidth": "0", "display": "flex", "flexDirection": "column", "alignItems": "center", }, ) # create a special plane toggle block with the slider plane_block_layout = control_block( "Plane", html.Div( [ html.Div( labeled_toggle( "Ecl", "plane-toggle", "Equ", value=plane_equ_default, fg=fg0, width_pix=220 ), style={ "display": "flex", "justifyContent": "center", "width": "100%", "maxWidth": "260px", "margin": "0 auto 14px auto", }, ), html.Div( [ html.Div( "Opacity", style={**sublabel_style(fg0), "flex": "1 1 auto", "textAlign": "center"}, ), html.Div( "Colour", style={**sublabel_style(fg0), "flex": "0 0 42px", "textAlign": "center"} ), ], style={ "width": "100%", "maxWidth": "260px", "display": "flex", "gap": "10px", "margin": "0 auto 6px auto", }, ), html.Div( [ html.Div( dcc.Slider( id="plane-opacity", min=0.0, max=1.0, step=0.02, value=opacity_default, marks={ 0.0: {"label": "0"}, 0.5: {"label": "0.5"}, 1.0: {"label": "1.0"}, }, className="opacity-slider", ), style={"flex": "1 1 auto", "minWidth": "0"}, ), html.Button( "\U0001f3a8", id="plane-colour-button", n_clicks=0, style={ "flex": "0 0 42px", "width": "42px", "height": "34px", "borderRadius": "10px", "border": f"1px solid {theme0['border']}", "background": theme0["tab_bg"], "color": fg0, "cursor": "pointer", "padding": "0", "display": "flex", "alignItems": "center", "justifyContent": "center", }, title="Edit Colour", ), ], style={ "width": "100%", "maxWidth": "260px", "margin": "0 auto", "display": "flex", "alignItems": "center", "gap": "10px", }, ), ], style={ "display": "flex", "flexDirection": "column", "alignItems": "center", "gap": "10px", "width": "100%", "minWidth": "0", }, ), fg=fg0, ) # create a special switch for 2D plots to choose which panels to display and how many panel_mode_block = html.Div( [ html.Div( "2D Panel Controls", style={**sublabel_style(fg0), "textAlign": "center", "marginBottom": "6px"}, ), dcc.RadioItems( id="panel-mode", options=[ {"label": "1 panel", "value": "single"}, {"label": "2 panels", "value": "double"}, ], value="double", inline=True, style={"display": "flex", "justifyContent": "center", "gap": "18px"}, labelStyle={**sublabel_style(fg0), "cursor": "pointer"}, ), ], style={ "width": "220px", "display": "flex", "flexDirection": "column", "alignItems": "center", "marginTop": "14px", }, ) # -- create entire layout of page -- app.layout = html.Div( [ # these store camera/zoom info across theme/origin/plane changes, whether the # controls drawer is open or not, and the height of the drawer dcc.Store(id="view-state", data={"mode": None, "camera": None, "xrange": None, "yrange": None}), dcc.Store(id="controls-open", data=False), dcc.Store(id="drawer-height", data=0), # our visible object sets dcc.Store(id="visible-objids", data=inv_objids), dcc.Store(id="visible-planets", data=inv_planets), # per-orbit colour overrides {objid: hex_colour} dcc.Store(id="orbit-colour-map", data={}), # orbit ID targeted by the currently open colour picker dcc.Store(id="orbit-colour-target", data=None), # IDs of "special" orbits (set once at startup, never changes) dcc.Store(id="orbit-special-ids", data=list(special_ids or [])), # here's our button to toggle down the settings menu html.Button( "⌄", id="controls-tab", n_clicks=0, style={ "position": "fixed", "top": "10px", "left": "50%", "transform": "translateX(-50%)", "zIndex": 10000, "pointerEvents": "auto", "width": "64px", "height": "36px", "borderRadius": "14px", "border": f"1px solid {theme0['border']}", "background": theme0["tab_bg"], "color": fg0, "fontSize": "26px", "cursor": "pointer", "backdropFilter": "blur(10px)", "transition": "top 180ms ease", }, ), html.Div( [ html.Div( [ html.Div( [ html.Div("", style={"justifySelf": "start"}), html.Div( "Controls", style={ "fontSize": "22px", "fontWeight": 600, "justifySelf": "center", }, ), html.Button( "x", id="controls-close", n_clicks=0, style={ "justifySelf": "end", "border": "none", "background": "transparent", "color": "inherit", "fontSize": "22px", "cursor": "pointer", }, ), ], style={ "display": "grid", "gridTemplateColumns": "1fr auto 1fr", "alignItems": "center", "marginBottom": "14px", }, ), html.Div( html.Div( [ control_block( "Theme", labeled_toggle( "Day", "theme-toggle", "Night", value=night_default, fg=fg0, width_pix=220, ), fg=fg0, width_px=220, ), control_block( "Origin", labeled_toggle( "Helio", "origin-toggle", "Bary", value=origin_bary_default, fg=fg0, width_pix=220, ), fg=fg0, width_px=220, ), plane_block_layout, html.Div( [ control_block( "View", labeled_toggle( "2D", "view-toggle", "3D", value=view_3d_default, fg=fg0, width_pix=200, ), fg=fg0, width_px=220, ), html.Div( panel_mode_block, id="panel-controls-wrapper", style={ "display": "none" }, # <-- start hidden since default view is 3D ), ], style={ "display": "flex", "flexDirection": "column", "alignItems": "center", "gap": "10px", "width": "100%", "minWidth": "0", }, ), control_block( "Edit Objects", html.Div( html.Button( [html.Span("✎", style={"marginRight": "10px"}), "Edit"], id="objects-open", n_clicks=0, style={ "width": "180px", "height": "44px", "borderRadius": "14px", "border": f"1px solid {theme0['border']}", "background": theme0["tab_bg"], "color": fg0, "fontSize": "20px", "fontWeight": 600, "cursor": "pointer", }, ), style={"marginTop": "6px"}, ), fg=fg0, width_px=220, ), ], id="controls-row", style={ "display": "grid", "gridTemplateColumns": "repeat(5, minmax(260px, 1fr))", # <-- this makes it an evenly spaced grid of 5 objects "alignItems": "start", "gap": "32px", "width": "100%", "boxSizing": "border-box", "justifyItems": "stretch", }, ), style={ "width": "100%", "overflowX": "auto", "overflowY": "visible", "paddingLeft": "32px", "paddingRight": "32px", "boxSizing": "border-box", }, ), ], id="controls-content", style={ "padding": "18px", "paddingBottom": "64px", "boxSizing": "border-box", "background": theme0["drawer_bg"], "color": fg0, "borderBottom": f"1px solid {theme0['border']}", "backdropFilter": "blur(10px)", }, ) ], id="controls-drawer", style={ "position": "fixed", "top": "0", "left": "0", "right": "0", "zIndex": 4000, "background": "rgba(0,0,0,0)", "borderBottom": "none", "backdropFilter": "none", "overflow": "visible", "display": "flex", "flexDirection": "column", "transform": "translateY(-100%)", "transition": "transform 180ms ease", "pointerEvents": "none", }, ), html.Div( [ # this is the actual plot being drawn dcc.Graph( id="orbit-graph", figure=initial_fig, style={"width": "100%", "height": "100%"}, config={"displaylogo": False, "responsive": True, "displayModeBar": True}, ), # panel dropdown overlay (on-plot, not in drawer) html.Div( [ dcc.Dropdown( id="panel-single", options=[{"label": p, "value": p} for p in ["XY", "XZ", "YZ"]], value="XY", clearable=False, style={"width": "110px", "color": "#111111"}, ), ], id="panel-overlay-single", style={ "position": "absolute", "left": "50%", "top": "10px", "transform": "translateX(-50%)", "zIndex": 2000, "display": "none", "padding": "10px 12px", "borderRadius": "14px", "background": theme0["tab_bg"], "border": f"1px solid {theme0['border']}", "backdropFilter": "blur(10px)", "pointerEvents": "auto", }, ), html.Div( [ dcc.Dropdown( id="panel-left", options=[{"label": p, "value": p} for p in ["XY", "XZ", "YZ"]], value="XY", clearable=False, style={"width": "110px", "color": "#111111"}, ), ], id="panel-overlay-left", style={ "position": "absolute", "left": "25%", "top": "10px", "transform": "translateX(-50%)", "zIndex": 2000, "display": "none", "padding": "10px 12px", "borderRadius": "14px", "background": theme0["tab_bg"], "border": f"1px solid {theme0['border']}", "backdropFilter": "blur(10px)", "pointerEvents": "auto", }, ), html.Div( [ dcc.Dropdown( id="panel-right", options=[{"label": p, "value": p} for p in ["XY", "XZ", "YZ"]], value="YZ", clearable=False, style={"width": "110px", "color": "#111111"}, ) ], id="panel-overlay-right", style={ "position": "absolute", "left": "75%", "top": "10px", "transform": "translateX(-50%)", "zIndex": 2000, "display": "none", "padding": "10px 12px", "borderRadius": "14px", "background": theme0["tab_bg"], "border": f"1px solid {theme0['border']}", "backdropFilter": "blur(10px)", "pointerEvents": "auto", }, ), ], style={"position": "fixed", "inset": "0", "zIndex": 1}, ), # make the object table modal manager html.Div( id="objects-modal", style={ "display": "none", "position": "fixed", "inset": 0, "zIndex": 20000, "background": "rgba(0,0,0,0.55)", "backdropFilter": "blur(6px)", "pointerEvents": "auto", }, children=[ html.Div( id="objects-modal-card", style={ "width": "min(1200px, 96vw)", "height": "min(820px, 92vh)", "margin": "4vh auto", "background": theme0["drawer_bg"], "border": f"1px solid {theme0['border']}", "borderRadius": "16px", "padding": "16px", "boxSizing": "border-box", "display": "flex", "flexDirection": "column", "gap": "12px", }, children=[ html.Div( style={ "display": "flex", "alignItems": "center", "justifyContent": "space-between", "gap": "10px", }, children=[ html.Div( "Object Manager", style={"fontSize": "22px", "fontWeight": 700, "color": fg0}, ), html.Button( "x", id="objects-close", n_clicks=0, style={ "border": "none", "background": "transparent", "color": fg0, "fontSize": "22px", "cursor": "pointer", }, ), ], ), html.Div( style={ "display": "flex", "gap": "10px", "flexWrap": "wrap", "alignItems": "center", }, children=[ dcc.Input( id="obj-quickfilter", type="text", placeholder="Search... (filter rows)", value="", style={ "width": "340px", "height": "40px", "borderRadius": "12px", "border": f"1px solid {theme0['border']}", "padding": "0 12px", "outline": "none", }, ), html.Div( id="obj-count", style={"color": fg0, "opacity": 0.9, "fontSize": "14px"}, ), ], ), html.Div( style={ "display": "flex", "gap": "10px", "flexWrap": "wrap", "alignItems": "center", }, children=[ html.Button("Show selected", id="obj-show-selected", n_clicks=0), html.Button("Hide selected", id="obj-hide-selected", n_clicks=0), html.Button("Show filtered", id="obj-show-filtered", n_clicks=0), html.Button("Hide filtered", id="obj-hide-filtered", n_clicks=0), html.Button("Show all", id="obj-show-all", n_clicks=0), html.Button("Hide all", id="obj-hide-all", n_clicks=0), html.Button("Invert Selection", id="obj-invert", n_clicks=0), ], ), html.Div( style={"flex": "1 1 auto", "minHeight": "0"}, children=[ dag.AgGrid( id="objects-grid", columnDefs=[ { "headerName": "Kind", "field": "kind", "width": 120, "filter": True, }, { "headerName": "Name", "field": "name", "flex": 1, "filter": True, "checkboxSelection": True, "headerCheckboxSelection": True, }, { "headerName": "Visible", "field": "visible", "width": 100, "filter": True, }, { "headerName": "Colour", "field": "colour", "width": 90, "cellStyle": { "function": ( "params.value" " ? {backgroundColor: params.value, cursor: 'pointer', borderRadius: '3px'}" " : {border: '2px dashed rgba(128,128,128,0.5)', cursor: 'pointer', borderRadius: '3px'}" # <-- make a fancy dashed box for colours per object ) }, "sortable": False, }, ], rowData=[], defaultColDef={"sortable": True, "resizable": True}, dangerously_allow_code=True, # <-- this is only because i want to mimic javascript clicking of the colour picker, and we've hardcoded in the function in the app, so no danger really dashGridOptions={ "rowSelection": "multiple", "animateRows": False, "suppressRowClickSelection": False, "rowHeight": 34, }, className="ag-theme-quartz", style={"height": "100%", "width": "100%"}, ) ], ), ], ) ], ), # make the colour selector window dcc.Store(id="plane-colour-open", data=False), html.Div( id="plane-colour-modal", n_clicks=0, style={ "display": "none", "position": "fixed", "inset": 0, "zIndex": 25000, "background": "rgba(0,0,0,0.55)", "backdropFilter": "blur(6px)", "pointerEvents": "auto", }, children=[ html.Div( id="plane-colour-card", n_clicks=0, style={ "width": "min(520px, 92vw)", "margin": "12vh auto", "background": theme0["drawer_bg"], "border": f"1px solid {theme0['border']}", "borderRadius": "16px", "padding": "16px", "boxSizing": "border-box", }, children=[ html.Div( style={ "display": "flex", "alignItems": "center", "justifyContent": "space-between", }, children=[ html.Div( "Reference Plane Colour", style={"fontSize": "18px", "fontWeight": 700, "color": fg0}, ), html.Button( "x", id="plane-colour-close", n_clicks=0, style={ "border": "none", "background": "transparent", "color": fg0, "fontSize": "22px", "cursor": "pointer", }, ), ], ), html.Div( style={"marginTop": "12px", "display": "flex", "justifyContent": "center"}, children=[ daq.ColorPicker(id="plane-colour", value={"hex": "#F5B277"}, size=220) ], ), ], ) ], ), # orbit colour picker modal html.Div( id="orbit-colour-modal", n_clicks=0, style={ "display": "none", "position": "fixed", "inset": 0, "zIndex": 26000, "background": "rgba(0,0,0,0.55)", "backdropFilter": "blur(6px)", "pointerEvents": "auto", }, children=[ html.Div( id="orbit-colour-card", n_clicks=0, style={ "width": "min(520px, 92vw)", "margin": "12vh auto", "background": theme0["drawer_bg"], "border": f"1px solid {theme0['border']}", "borderRadius": "16px", "padding": "16px", "boxSizing": "border-box", }, children=[ html.Div( style={ "display": "flex", "alignItems": "center", "justifyContent": "space-between", }, children=[ html.Div( "Orbit Colour", style={"fontSize": "18px", "fontWeight": 700, "color": fg0}, ), html.Button( "x", id="orbit-colour-close", n_clicks=0, style={ "border": "none", "background": "transparent", "color": fg0, "fontSize": "22px", "cursor": "pointer", }, ), ], ), html.Div( style={ "marginTop": "12px", "display": "flex", "justifyContent": "center", }, children=[ daq.ColorPicker( id="orbit-colour-picker", value={"hex": "#90A7D1"}, size=220, ) ], ), html.Div( style={ "marginTop": "12px", "display": "flex", "justifyContent": "center", "gap": "12px", }, children=[ html.Button( "Apply", id="orbit-colour-apply", n_clicks=0, style={ "padding": "8px 20px", "borderRadius": "10px", "border": f"1px solid {theme0['border']}", "background": theme0["tab_bg"], "color": fg0, "fontSize": "16px", "cursor": "pointer", }, ), html.Button( "Reset to default", id="orbit-colour-reset", n_clicks=0, style={ "padding": "8px 20px", "borderRadius": "10px", "border": f"1px solid {theme0['border']}", "background": theme0["tab_bg"], "color": fg0, "fontSize": "16px", "cursor": "pointer", }, ), ], ), ], ) ], ), ], id="app-shell", className="theme-dark" if night_default else "theme-light", style={ "height": "100vh", "width": "100vw", "margin": "0", "padding": "0", "backgroundColor": theme0["bg"], "color": fg0, }, ) # -- interactive -- # this bit does a bit of javascript-y hackery: basically 1) whenever the controls-open state # changes, we grab its id, then 2) get its pixel dimensions on screen (r = ...), then 3) we # get that height (or 0 if missing) and store it in drawer-height - all of this is done in web app.clientside_callback( """ function(is_open) { const el = document.getElementById("controls-drawer"); if (!el) return 0; const r = el.getBoundingClientRect(); return (r && r.height) ? r.height : 0; } """, Output("drawer-height", "data"), Input("controls-open", "data"), ) # this bit lets the code know what state the dropdown controls tab is in (open/closed?) @app.callback( Output("controls-open", "data"), Input("controls-tab", "n_clicks"), # <-- toggle tab open/close Input("controls-close", "n_clicks"), # <-- force close State("controls-open", "data"), prevent_initial_call=True, ) def toggle_controls(_tab_clicks: int | None, _close_clicks: int | None, is_open: bool): trig = ( ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" ) # <-- this registers what input fires the callback if trig == "controls-close": return False # <-- if the X button is pressed, force closed return not bool( is_open ) # <-- if the \/ button is pressed, invert whatever state is_open is in (ie close/open) # now we will alter the position, state, movement, and buttons on the controls drawer here @app.callback( Output("controls-drawer", "style"), Output("controls-tab", "children"), Output("controls-tab", "style"), Input("controls-open", "data"), Input("theme-toggle", "value"), Input("drawer-height", "data"), State("controls-drawer", "style"), State("controls-tab", "style"), ) def style_drawer( is_open: bool, night_mode: bool, drawer_h: float | int | None, drawer_style: dict[str, Any] | None, tab_style: dict[str, Any] | None, ): theme = THEME["night"] if night_mode else THEME["day"] fg = theme["fg"] # alter some aspects of the drawer (note: in this context a drawer is the slidey box # that comes down when we press the \/ button). note that the style differs if it is # open or closed ds = dict(drawer_style or {}) ds["background"] = "rgba(0,0,0,0)" ds["borderBottom"] = "none" ds["backdropFilter"] = "none" ds["overflow"] = "visible" ds["display"] = "flex" ds["flexDirection"] = "column" ds["transition"] = "transform 180ms ease" # <-- make it smooth pulling out ds["transform"] = "translateY(0%)" if is_open else "translateY(-100%)" ds["zIndex"] = 4000 ds["pointerEvents"] = ( "auto" if is_open else "none" ) # <-- makes sure it wont block the plotly plot elements when closed # alter some aspects of the button (note: in this context its also called a tab). the # colour and theme changes depending on day/night mode ts = dict(tab_style or {}) ts["position"] = "fixed" ts["left"] = "50%" ts["transform"] = "translateX(-50%)" ts["zIndex"] = 10000 ts["pointerEvents"] = "auto" ts["width"] = "64px" ts["height"] = "36px" ts["cursor"] = "pointer" ts["fontSize"] = "26px" ts["borderRadius"] = "14px" ts["background"] = theme["tab_bg"] ts["color"] = fg ts["border"] = f"1px solid {theme['border']}" ts["backdropFilter"] = "blur(10px)" ts["transition"] = "top 180ms ease" # this bit places the \/ button either near the top (10px) when closed, or near the bottom edge # of the drawer (inset top_px) when open try: h = float(drawer_h) if drawer_h is not None else 0.0 except Exception: h = 0.0 if is_open and h > 0: top_px = max( 10, int(h - 36 - 10) ) # <-- drawer bottom (h) - button height (36) - small padding (10) ts["top"] = f"{top_px}px" else: ts["top"] = "10px" chevron = "⌃" if is_open else "⌄" return ds, chevron, ts # reference plane colour picker @app.callback( Output("plane-colour-modal", "style", allow_duplicate=True), Output("plane-colour-card", "style"), Input("plane-colour-button", "n_clicks"), Input("plane-colour-close", "n_clicks"), Input("theme-toggle", "value"), State("plane-colour-modal", "style"), State("plane-colour-card", "style"), prevent_initial_call=True, ) def toggle_plane_colour_modal( open_clicks: int | None, close_clicks: int | None, night_mode: bool, modal_style: dict[str, Any] | None, card_style: dict[str, Any] | None, ): theme = THEME["night"] if night_mode else THEME["day"] ms = dict(modal_style or {}) cs = dict(card_style or {}) # keep theme in sync cs["background"] = theme["drawer_bg"] cs["border"] = f"1px solid {theme['border']}" trig = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" if trig == "plane-colour-button": ms["display"] = "block" elif trig == "plane-colour-close": ms["display"] = "none" return ms, cs # add ability to close out of colour picker by just selecting backdrop @app.callback( Output("plane-colour-modal", "style", allow_duplicate=True), Input("plane-colour-modal", "n_clicks"), Input("plane-colour-card", "n_clicks"), State("plane-colour-modal", "style"), prevent_initial_call=True, ) def close_plane_colour_on_backdrop( modal_clicks: int | None, card_clicks: int | None, style: dict[str, Any] | None ): trig = ctx.triggered_id # if the backdrop itself was clicked, then close altogether if trig == "plane-colour-modal": style = dict(style or {}) style["display"] = "none" return style # but if click was inside the colour picker card, do nothing raise dash.exceptions.PreventUpdate # object manager callback stuff (open/close + theming) @app.callback( Output("objects-modal", "style"), Output("objects-modal-card", "style"), Input("objects-open", "n_clicks"), Input("objects-close", "n_clicks"), Input("theme-toggle", "value"), State("objects-modal", "style"), State("objects-modal-card", "style"), prevent_initial_call=True, ) def toggle_objects_modal( open: int | None, close: int | None, night_mode: bool, modal_style: dict[str, Any] | None, card_style: dict[str, Any] | None, ): theme = THEME["night"] if night_mode else THEME["day"] fg = theme["fg"] ms = dict(modal_style or {}) cs = dict(card_style or {}) # keep theme in sync even if modal already open cs["background"] = theme["drawer_bg"] cs["border"] = f"1px solid {theme['border']}" trig = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" if trig == "objects-open": ms["display"] = "block" elif trig == "objects-close": ms["display"] = "none" return ms, cs # add search filtering @app.callback(Output("objects-grid", "quickFilterText"), Input("obj-quickfilter", "value")) def grid_quickfilter(txt: str | None): return txt or "" # make orbits visible / invisibile from table selections @app.callback( Output("objects-grid", "rowData"), Output("obj-count", "children"), Input("visible-objids", "data"), Input("visible-planets", "data"), Input("orbit-colour-map", "data"), ) def sync_grid_rows( visible_objids: list[str] | None, visible_planets: list[str] | None, orbit_colour_map: dict[str, str] | None, ): vis_o = set(visible_objids or []) vis_p = set(visible_planets or []) cm = orbit_colour_map or {} rows = [] for r in inventory_rows: k = r["kind"] name = r["name"] if k == "Planet": vis = name in vis_p colour = "" else: vis = name in vis_o colour = cm.get(name, "") rows.append({"kind": k, "name": name, "visible": "✓" if vis else "", "colour": colour}) shown = len(vis_o) + len(vis_p) total = len(inv_objids) + len(inv_planets) return rows, f"Visible: {shown} / {total}" # bulk actions from the manager @app.callback( Output("visible-objids", "data"), Output("visible-planets", "data"), Input("obj-show-selected", "n_clicks"), Input("obj-hide-selected", "n_clicks"), Input("obj-show-filtered", "n_clicks"), Input("obj-hide-filtered", "n_clicks"), Input("obj-show-all", "n_clicks"), Input("obj-hide-all", "n_clicks"), Input("obj-invert", "n_clicks"), State("objects-grid", "selectedRows"), State("objects-grid", "virtualRowData"), State("visible-objids", "data"), State("visible-planets", "data"), prevent_initial_call=True, ) def bulk_visibility( n1: int | None, n2: int | None, n3: int | None, n4: int | None, n5: int | None, n6: int | None, n7: int | None, selected_rows: list[dict[str, Any]] | None, virtual_rows: list[dict[str, Any]] | None, visible_objids: list[str] | None, visible_planets: list[str] | None, ): # /\ all of the n* are the various click values for the inputs, if they're not there plotly and dash break trig = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" vis_o = set(visible_objids or []) vis_p = set(visible_planets or []) def split(rows: list[dict[str, Any]] | None): rows = rows or [] ps = { str(r.get("name")) for r in rows if str(r.get("kind")) == "Planet" and r.get("name") is not None } os = { str(r.get("name")) for r in rows if str(r.get("kind")) != "Planet" and r.get("name") is not None } return ps, os sel_p, sel_o = split(selected_rows) fil_p, fil_o = split(virtual_rows) # joe learns logical operators: if trig == "obj-show-selected": vis_p |= sel_p vis_o |= sel_o elif trig == "obj-hide-selected": vis_p -= sel_p vis_o -= sel_o elif trig == "obj-show-filtered": vis_p |= fil_p vis_o |= fil_o elif trig == "obj-hide-filtered": vis_p -= fil_p vis_o -= fil_o elif trig == "obj-show-all": vis_p = set(inv_planets) vis_o = set(inv_objids) elif trig == "obj-hide-all": vis_p = set() vis_o = set() elif trig == "obj-invert": vis_p = set(inv_planets) - vis_p vis_o = set(inv_objids) - vis_o else: raise dash.exceptions.PreventUpdate return sorted(vis_o), sorted(vis_p) # open orbit colour picker when a colour-column cell is clicked @app.callback( Output("orbit-colour-modal", "style", allow_duplicate=True), Output("orbit-colour-card", "style"), Output("orbit-colour-picker", "value"), Output("orbit-colour-target", "data"), Input("objects-grid", "cellClicked"), Input("orbit-colour-close", "n_clicks"), Input("theme-toggle", "value"), State("orbit-colour-map", "data"), State("objects-grid", "virtualRowData"), State("orbit-colour-modal", "style"), State("orbit-colour-card", "style"), State("orbit-colour-picker", "value"), State("orbit-colour-target", "data"), prevent_initial_call=True, ) def open_orbit_colour_picker( cell_clicked, close_clicks: int | None, night_mode: bool, colour_map: dict[str, str] | None, row_data: list[dict[str, Any]] | None, modal_style: dict[str, Any] | None, card_style: dict[str, Any] | None, picker_value, target: str | None, ): theme = THEME["night"] if night_mode else THEME["day"] ms = dict(modal_style or {}) cs = dict(card_style or {}) cs["background"] = theme["drawer_bg"] cs["border"] = f"1px solid {theme['border']}" cm = colour_map or {} trig = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" if trig == "objects-grid": if not cell_clicked or cell_clicked.get("colId") != "colour": raise dash.exceptions.PreventUpdate # cellClicked gives rowIndex but not the row data itself row_index = cell_clicked.get("rowIndex") if row_index is None or not row_data: raise dash.exceptions.PreventUpdate row = row_data[row_index] if row.get("kind") == "Planet" or not row.get("name"): raise dash.exceptions.PreventUpdate obj_id = str(row["name"]) current_hex = cm.get(obj_id, "#90A7D1") ms["display"] = "block" return ms, cs, {"hex": current_hex}, obj_id if trig == "orbit-colour-close": ms["display"] = "none" return ms, cs, picker_value, target # close by clicking the backdrop @app.callback( Output("orbit-colour-modal", "style", allow_duplicate=True), Input("orbit-colour-modal", "n_clicks"), Input("orbit-colour-card", "n_clicks"), State("orbit-colour-modal", "style"), prevent_initial_call=True, ) def close_orbit_colour_on_backdrop( modal_clicks: int | None, card_clicks: int | None, style: dict[str, Any] | None ): if ctx.triggered_id == "orbit-colour-modal": style = dict(style or {}) style["display"] = "none" return style raise dash.exceptions.PreventUpdate # apply colour or reset to default for the targeted orbit @app.callback( Output("orbit-colour-map", "data"), Output("orbit-colour-modal", "style", allow_duplicate=True), Input("orbit-colour-apply", "n_clicks"), Input("orbit-colour-reset", "n_clicks"), State("orbit-colour-picker", "value"), State("orbit-colour-target", "data"), State("orbit-colour-map", "data"), State("orbit-colour-modal", "style"), prevent_initial_call=True, ) def update_orbit_colour_map( apply_clicks: int | None, reset_clicks: int | None, picker_value, target: str | None, colour_map: dict[str, str] | None, modal_style: dict[str, Any] | None, ): trig = ctx.triggered[0]["prop_id"].split(".")[0] if ctx.triggered else "" colour_map = dict(colour_map or {}) ms = dict(modal_style or {}) if trig == "orbit-colour-apply": if target and picker_value and "hex" in picker_value: colour_map[target] = picker_value["hex"] ms["display"] = "none" elif trig == "orbit-colour-reset": if target: colour_map.pop(target, None) ms["display"] = "none" else: raise dash.exceptions.PreventUpdate return colour_map, ms # this section records the zoom/camera state into view-state @app.callback( Output("view-state", "data"), Input("orbit-graph", "relayoutData"), State("view-toggle", "value"), State("view-state", "data"), prevent_initial_call=True, ) def capture_view(relayout: dict[str, Any] | None, view_3d: bool, state: dict[str, Any] | None): # ignore any callbacks that don't actually change the view if not relayout: return dash.no_update # ignore relayouts that are just layout/style updates camera_keys = { "scene.camera", "xaxis.range[0]", "xaxis.range[1]", "yaxis.range[0]", "yaxis.range[1]", "xaxis.autorange", "yaxis.autorange", } if not any(k in relayout for k in camera_keys): return dash.no_update # figure out what mode we're in state = dict(state or {}) mode = "3d" if view_3d else "2d" # if the mode has changed since last time, reset the stored view # (viewpoint between 2d <-> 3d shouldn't be saved) if state.get("mode") != mode: state = {"mode": mode, "camera": None, "xrange": None, "yrange": None} state["mode"] = mode # if we're in 3d and the camera exists, store it if mode == "3d": cam = relayout.get("scene.camera") if cam: state["camera"] = cam # however if we're in 2d, store the x/y axes ranges else: x0 = relayout.get("xaxis.range[0]") x1 = relayout.get("xaxis.range[1]") y0 = relayout.get("yaxis.range[0]") y1 = relayout.get("yaxis.range[1]") if x0 is not None and x1 is not None: state["xrange"] = [x0, x1] if y0 is not None and y1 is not None: state["yrange"] = [y0, y1] # or if the range is reset due to autoranging, clear stored ranges if relayout.get("xaxis.autorange") or relayout.get("yaxis.autorange"): state["xrange"] = None state["yrange"] = None return state # here we're gonna update everything that is actual content such as figures, rather # than just updating the drawer/toggle mechanics @app.callback( Output("orbit-graph", "figure"), Output("app-shell", "style"), Output("app-shell", "className"), Output("panel-controls-wrapper", "style"), Output("panel-overlay-single", "style"), Output("panel-overlay-left", "style"), Output("panel-overlay-right", "style"), Input("theme-toggle", "value"), Input("view-toggle", "value"), Input("origin-toggle", "value"), Input("plane-toggle", "value"), Input("plane-opacity", "value"), Input("plane-colour", "value"), Input("panel-mode", "value"), Input("panel-single", "value"), Input("panel-left", "value"), Input("panel-right", "value"), Input("visible-objids", "data"), Input("visible-planets", "data"), Input("orbit-colour-map", "data"), State("view-state", "data"), ) def update_orbit_plot( night_mode: bool, view_3d: bool, origin_bary: bool, plane_equ: bool, opacity: float, plane_colour, panel_mode: str, panel_single: str, panel_left: str, panel_right: str, visible_objids: list[str] | None, visible_planets: list[str] | None, orbit_colour_map: dict[str, str] | None, view_state: dict[str, object], ): # set up some figure themings theme = THEME["night"] if night_mode else THEME["day"] bg = theme["bg"] fg = theme["fg"] grid = theme["grid"] zero = theme["zero"] mode_key = "night" if night_mode else "day" orbit_colour = ORBIT_COLOUR[mode_key] special_colour = SPECIAL_COLOUR[mode_key] orbit_colour_dim = ORBIT_COLOUR_DIM[mode_key]["3d" if view_3d else "2d"] # set a default plot to show first origin = "bary" if origin_bary else "helio" plane = "equ" if plane_equ else "ecl" # deepcopy important as fig.data is mutable, so altering colours messes up cached figures and so toggles if view_3d: fig = copy.deepcopy(fig3d_cache[(origin, plane)]) else: if panel_mode == "single": fig = copy.deepcopy(fig2d_cache[(origin, plane, "single", panel_single, None)]) else: fig = copy.deepcopy(fig2d_cache[(origin, plane, "double", panel_left, panel_right)]) # do we have any special guys? has_special = bool(special_ids) for trace in fig.data: meta = getattr(trace, "meta", None) kind = meta.get("kind") if isinstance(meta, dict) else None # planets: colour by name if kind == "Planet": name = str(trace.name) trace.line.color = ( PLANET_COLOURS_NIGHT.get(name, "rgba(220,220,220,0.7)") if night_mode else PLANET_COLOURS_DAY.get(name, "rgba(220,220,220,0.7)") ) continue if trace.type not in ("scatter", "scatter3d") or getattr(trace, "mode", None) != "lines": continue # special orbits: accent colour if kind == "Special": trace.line = dict(color=special_colour, width=5.0) continue # regular orbits: dim if any special orbits exist, else normal if kind not in ("Special", "Planet"): if has_special: trace.line = dict(color=orbit_colour_dim, width=0.5 if view_3d else 1.5) else: trace.line = dict(color=orbit_colour, width=3.0 if view_3d else 1.5) # apply per-orbit colour overrides on top of the default # for regular orbits in has_special mode, preserve the dim alpha so # the override changes colour without popping the orbit to full opacity if orbit_colour_map: alpha_match = re.search( r",([\d.]+)\)$", orbit_colour_dim ) # <-- regex hax to get alpha value from rgba() string dim_alpha = float(alpha_match.group(1)) if alpha_match else 0.2 for trace in fig.data: if trace.type in ("scatter", "scatter3d") and getattr(trace, "mode", None) == "lines": meta = getattr(trace, "meta", None) kind = meta.get("kind") if isinstance(meta, dict) else None name = str(getattr(trace, "name", "")) if name in orbit_colour_map: hex_col = orbit_colour_map[name] if ( has_special and kind not in ("Special", "Planet") and hex_col.startswith("#") and len(hex_col) == 7 ): r = int(hex_col[1:3], 16) g = int(hex_col[3:5], 16) b = int(hex_col[5:7], 16) trace.line.color = f"rgba({r},{g},{b},{dim_alpha})" else: trace.line.color = hex_col # apply object visibility vis_o = set(visible_objids or []) vis_p = set(visible_planets or []) for trace in fig.data: meta = getattr(trace, "meta", None) kind = meta.get("kind") if isinstance(meta, dict) else None if kind == "Planet": trace.visible = str(getattr(trace, "name", "")) in vis_p continue if ( getattr(trace, "type", None) in ("scatter", "scatter3d") and getattr(trace, "mode", None) == "lines" ): name = str(getattr(trace, "name", "")) if name and name != "ref-plane": trace.visible = name in vis_o # if the view is 3d then set the reference plane opacity+colour if view_3d: for trace in fig.data: if getattr(trace, "name", None) == "ref-plane": trace.opacity = opacity if plane_colour and "hex" in plane_colour: c = plane_colour["hex"] trace.update(colorscale=[[0, c], [1, c]], cmin=0, cmax=1, showscale=False) # set up the background of the whole figure canvas + plotting region # so that they are transparent (ie page theme is controlling background, # not plotly itself) paper = "rgba(0,0,0,0)" if bg == "black" else "rgba(255,255,255,0)" fig.update_layout(paper_bgcolor=paper, plot_bgcolor=paper, font=dict(color=fg), uirevision="keep") # style the axis grids in 2d or 3d if hasattr(fig, "update_xaxes"): fig.update_xaxes(gridcolor=grid, zerolinecolor=zero) fig.update_yaxes(gridcolor=grid, zerolinecolor=zero) if getattr(fig.layout, "scene", None) is not None: fig.update_scenes( xaxis_gridcolor=grid, xaxis_zerolinecolor=zero, yaxis_gridcolor=grid, yaxis_zerolinecolor=zero, zaxis_gridcolor=grid, zaxis_zerolinecolor=zero, ) # get current view state view_state = view_state or {} mode = "3d" if view_3d else "2d" # if we're in the same view mode, reapply the stored view. this helps us # preserve the view across theme/origin/plane changes if view_state.get("mode") == mode: if mode == "3d" and view_state.get("camera") and getattr(fig.layout, "scene", None) is not None: fig.update_layout(scene_camera=view_state["camera"]) if mode == "2d": xr = view_state.get("xrange") yr = view_state.get("yrange") if xr is not None: fig.update_xaxes(range=xr, autorange=False) if yr is not None: fig.update_yaxes(range=yr, autorange=False) # set the style of the outermost html container shell_style = { "height": "100vh", "width": "100vw", "margin": "0", "padding": "0", "backgroundColor": bg, "color": fg, } shell_class = "theme-dark" if night_mode else "theme-light" # show/hide the 2D panel-mode selector under the view toggle panel_controls_style = {"display": "none"} if view_3d else {} # per-2D panel overlay base style base_overlay = { "position": "absolute", "top": "10px", "zIndex": 2000, "padding": "10px 12px", "borderRadius": "14px", "background": theme["tab_bg"], "border": f"1px solid {theme['border']}", "backdropFilter": "blur(10px)", "pointerEvents": "auto", "display": "none", } single_style = dict(base_overlay) left_style = dict(base_overlay) right_style = dict(base_overlay) # positions of dropdown menus single_style["left"] = "25%" single_style["transform"] = "translateX(-50%)" left_style["left"] = "25%" left_style["transform"] = "translateX(-50%)" right_style["left"] = "75%" right_style["transform"] = "translateX(-50%)" if not view_3d: if panel_mode == "single": single_style["display"] = "block" else: left_style["display"] = "block" right_style["display"] = "block" return fig, shell_style, shell_class, panel_controls_style, single_style, left_style, right_style # auto-open the browser def open_browser(): webbrowser.open_new("http://127.0.0.1:8050/") # give the server a moment to start before opening the browser threading.Timer(1.0, open_browser).start() # avoid spam in the terminal log = logging.getLogger("werkzeug") log.setLevel(logging.ERROR) # now run the server! app.run(debug=False, use_reloader=False)