Gaia Million Star Atlas#

Explore the sky, stellar color–magnitude diagram, and Galactic latitude fingerprint together. Try an approximate stellar population or brush either map to follow the same sources through all three views. Run with panel serve gaia_million_star_atlas.ipynb.

Data: ESA Gaia Archive, Gaia DR3, credited to ESA/Gaia/DPAC; CC BY-NC 3.0 IGO. This extract is a quality-selected, non-representative subset, not a complete Milky Way census.

import holoviews as hv
import numpy as np
import pandas as pd
import panel as pn
import param
from bokeh.models import ColumnDataSource, NumeralTickFormatter
from bokeh.plotting import figure
from holoviews.operation.datashader import rasterize

hv.extension("bokeh")
pn.extension(sizing_mode="stretch_width")

Data#

The Parquet extract was built with prepare_gaia.py, which sits next to this notebook. It queries gaiadr3.gaia_source on the Gaia TAP server in chunks of random_index, keeps sources with parallax_over_error > 5 and a measured bp_rp, and derives Galactic coordinates and a naive absolute G magnitude. pn.cache keeps the million rows in memory across sessions.

DATA_URL = "https://datasets.holoviz.org/gaia/v1/gaia_dr3_random12m_snr5.parquet"

@pn.cache
def load_stars():
    try:
        return pd.read_parquet(DATA_URL)
    except Exception as exc:
        raise RuntimeError(f"Could not load the Gaia DR3 extract from {DATA_URL}. Check network access and retry.") from exc

load_stars().head()
source_id galactic_longitude galactic_latitude bp_rp absolute_g phot_g_mean_mag parallax parallax_over_error
0 142718915684836992 149.690643 -17.651833 0.834055 4.466467 15.821225 0.535856 11.372714
1 143305333339310592 147.254959 -18.537783 1.634910 6.601814 15.211772 1.896743 58.367966
2 197494076720045440 165.669861 8.657578 1.472406 6.226052 16.885921 0.737949 11.284048
3 206189731939083264 161.659515 0.811167 1.292434 3.698066 16.160378 0.321764 7.677254
4 206559137776837120 160.379379 1.403567 1.178477 5.234590 14.912390 1.159952 47.657040

Selection statistics#

The population presets are HoloViews dim expressions that link_selections applies across every view. The statistics and the latitude histogram are plain functions of a selected frame, so they can be tested without a running app.

INK = "#f3f3ec"
MUTED = "#aabbd0"
TEAL = "#65e0db"
CORAL = "#ff987e"
PANEL = "#101d32"
BORDER = "#30445e"
DENSITY = ["#061125", "#12488e", "#198fe0", "#57e2da", "#f2b76c", "#fff5dd"]

BINS = np.arange(0, 95, 5)
LATITUDE = (BINS[:-1] + BINS[1:]) / 2

COLOR = hv.dim("bp_rp")
MAGNITUDE = hv.dim("absolute_g")
PRESETS = {
    "All stars": "all", "Blue + luminous": "blue", "Red giants": "giants", "Cool dwarfs": "dwarfs",
    "Brushed": "brush",
}
REGIONS = {
    "all": None,
    "blue": (COLOR < 0.9) & (MAGNITUDE < 3),
    "giants": (COLOR > 1.35) & (MAGNITUDE < 2.5),
    "dwarfs": (COLOR > 1.7) & (MAGNITUDE > 7),
}


def latitude_share(frame):
    counts, _ = np.histogram(np.abs(frame["galactic_latitude"]), bins=BINS)
    return 100 * counts / len(frame) if len(frame) else np.zeros(len(LATITUDE))


def disk_share(frame):
    return 100 * (np.abs(frame["galactic_latitude"]) < 10).mean()


def selection_stats(selected, total, comparison):
    count = len(selected)
    return {
        "count": count,
        "percent": 100 * count / total,
        "median_parallax": selected["parallax"].median() if count else None,
        "disk": disk_share(selected) if count else None,
        "comparison": comparison,
    }


def readout_text(stats):
    if not stats["count"]:
        return "No sources in this selection · 0.00% of extract · median parallax —"
    return (
        f"{stats['count']:,} sources · {stats['percent']:.2f}% of extract · "
        f"median measured parallax {stats['median_parallax']:.2f} mas"
    )


def insight_text(stats, baseline):
    if not stats["count"]:
        return "Try a wider box, another population, or clear the brush."
    if stats["comparison"]:
        return (
            f"Within |b| < 10°: {stats['disk']:.1f}% of selection vs {baseline:.1f}% of full extract. "
            "A sky brush constrains latitude directly; CMD regions compare populations within this quality-selected sample."
        )
    return (
        f"{baseline:.1f}% of the full extract lies within 10° of the Galactic plane. "
        "Choose a population or brush a plot to compare its sky distribution."
    )

Plots and cards#

Both maps are datashaded rasters of the same frame, so link_selections can brush either one. The latitude profile is a plain Bokeh figure whose ColumnDataSource is patched in place on every selection. Each view sits in the same styled card.

RASTER_OPTS = dict(
    cmap=DENSITY, cnorm="log", responsive="width", show_grid=False, bgcolor="#061125",
    colorbar=False, default_tools=["pan", "wheel_zoom", "reset"],
)


def sky_raster(frame):
    points = hv.Points(frame, ["galactic_longitude", "galactic_latitude"], ["bp_rp", "absolute_g"])
    return rasterize(points, width=1100, height=340).opts(
        clim=(1, 80), aspect=2.7, xlim=(-180, 180), ylim=(-90, 90),
        xlabel="Galactic longitude (°)", ylabel="Galactic latitude (°)", **RASTER_OPTS,
    )


def diagram_raster(frame):
    points = hv.Points(frame, ["bp_rp", "absolute_g"], ["galactic_longitude", "galactic_latitude"])
    return rasterize(points, width=700, height=340).opts(
        clim=(1, 160), aspect=1.8, xlim=(-0.5, 4), ylim=(-6, 16), invert_yaxis=True,
        xlabel="Color · BP − RP (mag)", ylabel="Absolute G magnitude (mag)", **RASTER_OPTS,
    )


def profile_figure(full, selected, y_end):
    fig = figure(
        height=350, sizing_mode="stretch_width", x_range=(0, 90), y_range=(0, y_end),
        x_axis_label="Absolute Galactic latitude |b| (deg)",
        y_axis_label="Share of sources per 5° bin (%)",
        toolbar_location=None, background_fill_color="#061125",
        border_fill_color=PANEL, outline_line_color=BORDER,
    )
    fig.varea(x="latitude", y1=0, y2="share", source=selected, fill_color=CORAL, fill_alpha=0.22)
    fig.line("latitude", "share", source=full, line_color=TEAL, line_width=2.5, legend_label="Full extract")
    fig.line("latitude", "share", source=selected, line_color=CORAL, line_width=3, legend_label="Selection")
    fig.xaxis.axis_label_text_color = INK
    fig.yaxis.axis_label_text_color = INK
    for axis in (fig.xaxis[0], fig.yaxis[0]):
        axis.major_label_text_color = MUTED
        axis.axis_line_color = "#55708f"
        axis.major_tick_line_color = "#55708f"
    fig.yaxis.formatter = NumeralTickFormatter(format="0.0")
    fig.grid.grid_line_color = BORDER
    fig.grid.grid_line_alpha = 0.4
    fig.legend.background_fill_color = PANEL
    fig.legend.background_fill_alpha = 0.7
    fig.legend.label_text_color = INK
    fig.legend.border_line_color = None
    return fig


def card(kicker, accent, title, view, caption):
    return pn.ui.Paper(
        pn.ui.Typography(kicker, sx={"color": accent, "fontSize": "0.72rem", "letterSpacing": "0.18em"}),
        pn.ui.Typography(title, sx={"color": INK, "fontFamily": "Georgia, serif", "fontSize": "1.35rem"}),
        view,
        pn.ui.Typography(caption, sx={"color": MUTED, "fontSize": "0.84rem"}),
        sx={"p": 2, "backgroundColor": PANEL, "border": f"1px solid {BORDER}"},
    )

Atlas state#

GaiaAtlas owns the selection. The preset radio group writes a region expression into link_selections, and a manual brush switches it to Brushed so the buttons never claim a selection they did not make. Each selection change schedules one callback that writes only the profile data and the stats dict; the readout and insight text are bound to stats.

class GaiaAtlas(pn.viewable.Viewer):
    preset = param.Selector(default="all", objects=PRESETS)

    reset = param.Event()

    stats = param.Dict()

    def __init__(self, **params):
        self.stars = load_stars()
        super().__init__(stats=selection_stats(self.stars, len(self.stars), False), **params)
        self._applying = False
        self._all_share = latitude_share(self.stars)
        self._disk_baseline = disk_share(self.stars)
        self.link = hv.link_selections.instance(
            selected_color=None, unselected_color="#314c71", unselected_alpha=0.13,
        )
        self._sky = pn.ui.HoloViews(self.link(sky_raster(self.stars)), linked_axes=False)
        self._diagram = pn.ui.HoloViews(self.link(diagram_raster(self.stars)), linked_axes=False)
        self._selected_source = ColumnDataSource(dict(latitude=LATITUDE, share=np.zeros_like(self._all_share)))
        self._profile = profile_figure(
            ColumnDataSource(dict(latitude=LATITUDE, share=self._all_share)),
            self._selected_source, self._all_share.max() * 1.15,
        )
        self._readout = pn.ui.Typography(
            pn.bind(readout_text, self.param.stats), sx={"color": INK, "fontSize": "0.97rem"},
        )
        self._insight = pn.ui.Typography(
            pn.bind(insight_text, self.param.stats, self._disk_baseline),
            sx={"color": MUTED, "fontSize": "0.86rem"},
        )
        self._presets = pn.ui.RadioButtonGroup.from_param(
            self.param.preset, label="", color="primary", min_width=300, max_width=760,
            # Five labelled buttons do not fit a phone width; wrap at natural width instead of overflowing.
            sx={"flexWrap": "wrap", "& .MuiToggleButton-root": {"flex": "1 0 auto", "width": "auto"}},
        )
        self._reset = pn.ui.Button.from_param(
            self.param.reset, label="Clear brush", icon="restart_alt", variant="outlined", width=145,
        )
        pn.bind(self._update_profile, self.link.selection_param(self.stars), watch=True)
        self.link.param.watch(self._sync_preset, "selection_expr")

    @param.depends("preset", watch=True)
    def _apply_preset(self):
        if self.preset == "brush":
            # Only a manual brush can produce this state; clicking it without one means no selection.
            if self.link.selection_expr is None:
                self.preset = "all"
            return
        self._applying = True
        try:
            # Replacing an active selection directly can leave stale HoloViews stream state.
            with pn.io.hold():
                self.link.selection_expr = None
                self.link.selection_expr = REGIONS[self.preset]
        finally:
            self._applying = False

    def _sync_preset(self, event):
        if not self._applying:
            self.preset = "all" if event.new is None else "brush"

    @param.depends("reset", watch=True)
    def _clear_selection(self):
        self.preset = "all"
        self.link.selection_expr = None

    def _update_profile(self, selected):
        selection = repr(self.link.selection_expr)
        comparison = self.link.selection_expr is not None
        stats = selection_stats(selected, len(self.stars), comparison)
        share = latitude_share(selected) if comparison else np.zeros_like(self._all_share)

        def update():
            # A newer selection may have arrived before this callback ran.
            if selection != repr(self.link.selection_expr):
                return
            self._selected_source.data = dict(latitude=LATITUDE, share=share)
            self._profile.y_range.end = max(5, max(self._all_share.max(), share.max()) * 1.15)
            self.stats = stats

        pn.state.execute(update, schedule=True)

    def __panel__(self):
        return pn.ui.Container(pn.ui.Column(
            pn.ui.Typography("GAIA DR3  /  AN INTERACTIVE FIELD ATLAS", sx={"color": TEAL, "letterSpacing": "0.2em", "fontSize": "0.72rem"}),
            pn.ui.Typography(
                "The Milky Way in a million measurements",
                sx={"color": INK, "fontFamily": "Georgia, serif",
                    "fontSize": {"xs": "1.9rem", "md": "2.5rem"}, "lineHeight": 1.1},
            ),
            pn.ui.Typography(
                "One selection, three views: trace a population from the sky to the "
                "color–magnitude diagram and its latitude distribution.",
                sx={"color": MUTED, "fontSize": "0.94rem"},
            ),
            pn.ui.Row(self._presets, self._reset, align="center", sx={"gap": "8px", "flexWrap": "wrap"}),
            pn.ui.Typography(
                "Approximate CMD regions (mag): blue + luminous (BP−RP < 0.9, MG < 3); "
                "red giants (BP−RP > 1.35, MG < 2.5); cool dwarfs (BP−RP > 1.7, MG > 7). "
                "Or box/lasso either raster.", sx={"color": MUTED, "fontSize": "0.81rem"},
            ),
            card(
                "01 / GALACTIC COORDINATES", TEAL, "The sky, unfolded", self._sky,
                "Density is shown on a logarithmic scale; the bright belt follows the Galactic plane.",
            ),
            pn.ui.Row(self._readout, sx={"borderLeft": f"3px solid {CORAL}", "paddingLeft": "14px"}),
            self._insight,
            pn.ui.Grid(
                pn.ui.Grid(card(
                    "02 / COLOR × ABSOLUTE G", CORAL, "A diagram of light and color", self._diagram,
                    "Brighter sources are higher; bluer sources are farther left. "
                    "Approximate regions are not spectral classifications.",
                ), size={"xs": 12, "md": 8}),
                pn.ui.Grid(card(
                    "03 / GALACTIC LATITUDE FINGERPRINT", TEAL, "Where do they gather?",
                    pn.ui.Bokeh(self._profile),
                    "After selecting, each curve sums to 100%. Compare shapes, not raw population sizes.",
                ), size={"xs": 12, "md": 4}),
                container=True, spacing=2,
            ),
            pn.ui.Divider(),
            pn.ui.Markdown(
                f"**About these measurements** · {len(self.stars):,} observed Gaia DR3 sources from "
                "`gaiadr3.gaia_source`, selected with `random_index < 12000000`, "
                "`parallax_over_error > 5`, and a measured `bp_rp`. This is a "
                "quality-selected, non-representative subset. Absolute G uses naive "
                "inverse parallax; no extinction correction is applied. "
                f"[Data extract]({DATA_URL}) · [ESA Gaia Archive](https://gea.esac.esa.int/archive/) "
                "· ESA/Gaia/DPAC · [CC BY-NC 3.0 IGO](https://creativecommons.org/licenses/by-nc/3.0/igo/).",
                styles={"color": MUTED},
            ),
            sx={"gap": "10px"}, margin=(12, 0),
        ), width_option="xl")

Page#

The dark Page shell wraps a single atlas instance, so the notebook preview and the served app share one set of linked plots.

atlas = GaiaAtlas()

app = pn.ui.Page(
    title="GAIA / FIELD ATLAS",
    dark_theme=True,
    theme_toggle=False,
    theme_config={
        "dark": {"palette": {
            "primary": {"main": TEAL},
            "background": {"default": "#071222", "paper": PANEL},
            "text": {"primary": INK, "secondary": MUTED},
        }},
    },
    sx={"& .header": {"backgroundColor": "#0b1a30", "backgroundImage": "none", "boxShadow": "none"}},
    main=[atlas],
)

app
if pn.state.served:
    app.servable()