Skip to content

ui Module

Web-based UI for interactive population simulation.

Overview

The ui subpackage provides launch(), Dashboard, and visualization utilities for interactive population simulation and data exploration. Requires nicegui.

Complete Module Reference

natal.ui

NATAL UI Module

Provides user interface components for visualizing and controlling simulations. Requires nicegui to be installed (pip install nicegui).

PopulationDashboard

PopulationDashboard(population: BasePopulation[Any])

A real-time dashboard for controlling and visualizing a NATAL population.

Initialize the population dashboard.

Sets up chart data structures, allele frequency tracking, observation panel, and UI state flags for the simulation control interface.

Parameters:

Name Type Description Default
population BasePopulation[Any]

The simulation population instance to monitor and control.

required

Raises:

Type Description
ImportError

If NiceGUI is not installed.

Source code in src/natal/ui/dashboard_population.py
def __init__(self, population: 'BasePopulation[Any]'):
    """Initialize the population dashboard.

    Sets up chart data structures, allele frequency tracking,
    observation panel, and UI state flags for the simulation
    control interface.

    Args:
        population: The simulation population instance to monitor
            and control.

    Raises:
        ImportError: If NiceGUI is not installed.
    """
    if not _has_nicegui:
        raise ImportError("NiceGUI is required. Please install it with: pip install nicegui")

    self.pop = population
    self._is_age_structured_population = isinstance(population, AgeStructuredPopulation)
    self.is_running = False
    self.is_processing = False
    self._tick_timer = None

    # UI Elements state
    self._chart_history: List[List[float]] = []
    self._allele_freq_history: Dict[str, List[List[float]]] = {}
    self.max_chart_points = 500  # Control total points for sparse display

    # Reconstruct chart history from existing population history (fixes data loss on reload)
    self.view_min: Optional[float] = None
    self.view_max: Optional[float] = None

    self._last_chart_tick = -1
    self._history_ticks: List[int] = []  # For efficient binary search in zoom
    self._rebuild_chart_history()
    self.inspected_tick: Optional[int] = None
    self.inspection_mode = False # False = Current, True = History

    # Observation panel (reusable component)
    self.obs_panel = ObservationPanel(
        genotype_labels=get_unordered_genotype_labels(
            self.pop.registry.index_to_genotype
        ),
        get_state=lambda: self.pop.state,
        get_registry=lambda: self.pop.registry,
    )
refresh_ui
refresh_ui()

Update reactive UI elements.

Source code in src/natal/ui/dashboard_population.py
def refresh_ui(self):
    """Update reactive UI elements."""
    # Determine which state to show
    if self.inspection_mode and self.inspected_tick is not None:
        # Inspecting history - handled by inspect_tick, but we might need to update static labels if we want
        return

    # Showing current state
    self._update_inspection_view(self.pop.state, self.pop.tick, is_history=False)
    self._update_charts()
reset_simulation
reset_simulation()

Reset the population and UI state.

Source code in src/natal/ui/dashboard_population.py
def reset_simulation(self):
    """Reset the population and UI state."""
    self.pop.reset()
    self.is_running = False
    self.inspection_mode = False
    self.inspected_tick = None
    self._last_chart_tick = -1
    self.tabs_main.set_value('inspection')

    # Reset UI controls
    if hasattr(self, 'btn_play'):
        self.btn_play.props('icon=play_arrow')
        self.btn_play.text = "Play"
    if hasattr(self, 'status_label'):
        self.status_label.text = "Ready"

    # Reset charts
    self.chart_pop.options['series'][0]['data'] = []  # type: ignore[reportUnknownMemberType]
    self.chart_allele.options['series'] = []  # type: ignore[reportUnknownMemberType]
    self._chart_history = []
    self._allele_freq_history = {}
    self.chart_pop.update()
    self.chart_allele.update()

    # Refresh to show initial state
    self.refresh_ui()
    ui.notify("Population reset to initial state.")
show_export_dialog
show_export_dialog()

Open a dialog to let the user select what to export.

Source code in src/natal/ui/dashboard_population.py
def show_export_dialog(self):
    """Open a dialog to let the user select what to export."""
    with ui.dialog() as self.export_dialog, ui.card():
        ui.label('Select items to export').classes('text-lg font-bold')
        self.cb_config = ui.checkbox('Configuration & Fitness', value=True)
        self.cb_history = ui.checkbox('Population History', value=True)
        self.cb_hooks = ui.checkbox('Hooks', value=True)
        with ui.row().classes('w-full justify-end gap-2 mt-4'):
            ui.button('Export', on_click=self._do_export)
            ui.button('Cancel', on_click=self.export_dialog.close).props('flat')
    self.export_dialog.open()
export_data
export_data()

Backwards-compatible export entrypoint.

Source code in src/natal/ui/dashboard_population.py
def export_data(self):
    """Backwards-compatible export entrypoint."""
    self._do_export_logic(include_config=True, include_history=True, include_hooks=True)
handle_chart_click
handle_chart_click(e)

Handle click on chart points to inspect history.

Source code in src/natal/ui/dashboard_population.py
def handle_chart_click(self, e):  # type: ignore[reportMissingParameterType, reportUnknownParameterType]
    """Handle click on chart points to inspect history."""
    # e.point_x contains the tick value
    if e.point_x is None:  # type: ignore[reportUnknownMemberType]
        return

    tick = int(e.point_x)  # type: ignore[reportUnknownMemberType]
    self.inspect_tick(tick)
handle_tick_input
handle_tick_input(e)

Handle manual tick input.

Source code in src/natal/ui/dashboard_population.py
def handle_tick_input(self, e):  # type: ignore[reportMissingParameterType, reportUnknownParameterType]
    """Handle manual tick input."""
    if e.value is None:  # type: ignore[reportUnknownMemberType]
        return
    self.inspect_tick(int(e.value))  # type: ignore[reportUnknownMemberType]
handle_chart_zoom
handle_chart_zoom(e)

Handle Highcharts 'selection' event to update view window.

Source code in src/natal/ui/dashboard_population.py
def handle_chart_zoom(self, e):  # type: ignore[reportMissingParameterType, reportUnknownParameterType]
    """Handle Highcharts 'selection' event to update view window."""
    # e.args format for selection event:
    # { 'xAxis': [ {'min': float, 'max': float, ...} ], ... } if zooming
    # { ... } (no xAxis) if resetting zoom

    # Highcharts on client side handles the visual zoom.
    # We update our internal view state and trigger a data refresh
    # to load higher resolution data for the selected range.
    if e.args and 'xAxis' in e.args and e.args['xAxis']:  # type: ignore[reportUnknownMemberType]
        self.view_min = e.args['xAxis'][0]['min']  # type: ignore[reportUnknownMemberType]
        self.view_max = e.args['xAxis'][0]['max']  # type: ignore[reportUnknownMemberType]
    else:
        self.view_min = None
        self.view_max = None
    self._update_charts()
inspect_tick
inspect_tick(tick: int)

Handle click on chart points to inspect history.

Source code in src/natal/ui/dashboard_population.py
def inspect_tick(self, tick: int):
    """Handle click on chart points to inspect history."""

    # Case 1: Inspecting the current live state (e.g. Tick 0 after reset, or latest tick)
    if tick == self.pop.tick:
        self.inspection_mode = False
        self.inspected_tick = None
        self._update_inspection_view(self.pop.state, tick, is_history=False)
        self.tabs_main.set_value('inspection')
        ui.notify(f"Inspecting Current State (Tick {tick})")
        return

    # Case 2: Inspecting historical state
    self.inspected_tick = tick
    self.inspection_mode = True

    # Try to find state in history
    # BasePopulation stores history as [(tick, flattened_array), ...]
    # We need to find the one matching tick
    history_record = next((rec for rec in self.pop._history if rec[0] == tick), None)

    if history_record:
        flat_state = history_record[1]
        # Parse state
        n_sexes = self.pop._config.n_sexes
        n_ages = self.pop._config.n_ages
        n_genotypes = self.pop._config.n_ztypes

        # Auto-detect state type based on flattened size
        expected_ind_size = int(n_sexes * n_ages * n_genotypes)
        if flat_state.size == 1 + expected_ind_size:
            state_obj = parse_flattened_discrete_state(flat_state, n_sexes, n_ages, n_genotypes, copy=False)
        else:
            state_obj = parse_flattened_state(flat_state, n_sexes, n_ages, n_genotypes, copy=False)

        self._update_inspection_view(state_obj, tick, is_history=True)  # type: ignore[reportArgumentType]
        self.tabs_main.set_value('inspection')
        ui.notify(f"Inspecting Tick {tick}")
    else:
        ui.notify(f"No history found for Tick {tick}", type="warning")
build_layout
build_layout()

Construct the NiceGUI layout.

Source code in src/natal/ui/dashboard_population.py
def build_layout(self):
    """Construct the NiceGUI layout."""
    assert ui is not None  # narrow for pyright after nicegui guard
    assert run is not None  # narrow for pyright after nicegui guard

    # --- Header ---
    with ui.header().classes('items-center justify-between bg-slate-900 text-white'):
        ui.label('🧬 NATAL Dashboard').classes('text-2xl font-bold')
        ui.label(f'Population: {self.pop.name}').classes('text-base opacity-80')

    # --- Left Drawer (Controls) ---
    with ui.left_drawer(value=True).classes('bg-gray-50 p-4 shadow-lg border-r').props('width=300'):
        ui.label('Control Panel').classes('text-xl font-bold text-gray-700 mb-4')

        # Status
        with ui.row().classes('items-center gap-2 mb-4 p-2 bg-white rounded border'):
            self.status_spinner = ui.spinner(size='sm').props('color=primary')
            self.status_label = ui.label('Ready').classes('text-base font-medium text-gray-600')
            self.status_spinner.visible = False

        # Live Stats
        ui.label('Current State').classes('text-sm font-bold text-gray-400 uppercase mb-2')
        with ui.grid(columns=2).classes('w-full gap-y-2 gap-x-4 mb-6'):
            ui.label('Tick:').classes('font-bold text-gray-600 text-lg')
            self.lbl_tick = ui.label(str(self.pop.tick)).classes('text-right font-mono text-lg')

            ui.label('Total:').classes('font-bold text-gray-600 text-lg')
            self.lbl_total = ui.label(str(self.pop.get_total_count())).classes('text-right font-mono text-lg')

            ui.label('Females:').classes('font-bold text-pink-600 text-lg')
            self.lbl_females = ui.label(str(self.pop.get_female_count())).classes('text-right font-mono text-pink-600 text-lg')

            ui.label('Males:').classes('font-bold text-blue-600 text-lg')
            self.lbl_males = ui.label(str(self.pop.get_male_count())).classes('text-right font-mono text-blue-600 text-lg')

        # Speed Control
        ui.label('Interval (s) (0=Unlimited)').classes('text-sm font-bold text-gray-400 uppercase mt-4 mb-2')
        self.slider_speed = ui.slider(min=0.0, max=0.2, value=0.05, step=0.005).props('label-always')
        self.slider_speed.on_value_change(self._update_timer_interval)

        # History Control
        ui.label('History Settings').classes('text-sm font-bold text-gray-400 uppercase mt-4 mb-2')
        with ui.grid(columns=2).classes('w-full gap-2'):
            ui.number(label='Record Every', value=self.pop.record_every, min=1, precision=0, on_change=self._update_record_every).classes('w-full')
            ui.number(label='Max History', value=self.pop.max_history, min=10, precision=0, on_change=self._update_max_history).classes('w-full')
        self.lbl_history_count = ui.label(f'(Current: {len(self.pop.history)} snapshots)').classes('text-sm text-gray-400 italic')

        ui.separator().classes('mb-4')

        # Control Buttons
        with ui.column().classes('w-full gap-2'):
            with ui.row().classes('w-full gap-2'):
                ui.button('Step', on_click=self._run_step).props('icon=skip_next outline').classes('flex-grow')

                def update_play_state(e: Any) -> None:  # Dash callback event; Any used for unknown event shape  # type: ignore[reportUnknownParameterType]
                    self._toggle_play()
                    icon = "pause" if self.is_running else "play_arrow"
                    text = "Pause" if self.is_running else "Play"
                    e.sender.props(f'icon={icon}')  # type: ignore[reportUnknownMemberType]
                    e.sender.text = text  # type: ignore[reportUnknownMemberType]

                self.btn_play = ui.button('Play', on_click=update_play_state).props('icon=play_arrow').classes('flex-grow')  # type: ignore[reportUnknownArgumentType]

            with ui.row().classes('w-full gap-2 mt-2'):
                ui.button('Reset', on_click=self.reset_simulation).props('icon=restart_alt flat color=grey').classes('flex-grow')
                ui.button('Export', on_click=self.show_export_dialog).props('icon=download flat color=grey').classes('flex-grow')

            def reset_zoom_and_update():
                self.reset_zoom()
                ui.notify("Zoom reset.")
            ui.button('Reset Zoom', on_click=reset_zoom_and_update).props('icon=zoom_out_map flat color=grey').classes('w-full mt-1')

    # --- Main Content ---
    with ui.column().classes('w-full p-4 gap-6'):

        # --- Charts Row ---
        with ui.card().classes('w-full p-0 gap-0 border-none shadow-sm'):
            with ui.row().classes('w-full no-wrap'):
                # Population Chart
                self.chart_pop = (
                    ui.highchart({  # type: ignore[reportUnknownMemberType]
                        'title': {'text': 'Population Size'},
                        'chart': {'type': 'line', 'animation': False, 'height': 300, 'zoomType': 'x'},
                        'xAxis': {'title': {'text': 'Tick'}},
                        'yAxis': {
                            'title': {'text': 'Count'},
                            'allowDecimals': not self._discrete_display(),
                        },
                        'series': [{'name': 'TotalPop', 'data': []}],
                        'plotOptions': {
                            'series': {
                                'dataGrouping': {'enabled': False},
                                'marker': {'enabled': False},
                                'cursor': 'pointer',
                                'events': {'click': True}  # Enable click events
                            }
                        }
                    }, on_point_click=self.handle_chart_click)  # type: ignore[reportUnknownMemberType]
                    .classes('w-1/2 h-80')
                    .on('selection', self.handle_chart_zoom, ['xAxis'])  # type: ignore[reportUnknownMemberType]
                )

                # Allele Freq Chart
                self.chart_allele = (
                    ui.highchart({  # type: ignore[reportUnknownMemberType]
                        'title': {'text': 'Allele Frequencies'},
                        'chart': {'type': 'line', 'animation': False, 'height': 300, 'zoomType': 'x'},
                        'xAxis': {'title': {'text': 'Tick'}},
                        'yAxis': {'title': {'text': 'Freq'}, 'max': 1.0, 'min': 0.0},
                        'series': [],
                        'plotOptions': {
                            'series': {
                                'dataGrouping': {'enabled': False},
                                'marker': {'enabled': False},
                                'cursor': 'pointer',
                                'events': {'click': True}
                            }
                        }
                    }, on_point_click=self.handle_chart_click)  # type: ignore[reportUnknownMemberType]
                    .classes('w-1/2 h-80')
                    .on('selection', self.handle_chart_zoom, ['xAxis'])  # type: ignore[reportUnknownMemberType]
                )

        # --- Tabs Section ---
        with ui.tabs().classes('w-full justify-start border-b') as tabs:
            self.tabs_main = tabs
            tab_inspect = ui.tab(name='inspection', label='Inspection', icon='search')
            tab_config = ui.tab('Configuration', icon='settings')
            tab_hooks = ui.tab('Hooks', icon='extension')
            tab_observation = ui.tab('Observation', icon='visibility')
            tab_rules = ui.tab('Genetics', icon='biotech')

        with ui.tab_panels(tabs, value='inspection').classes('w-full bg-transparent p-0'):

            # --- Tab 1: State Inspection ---
            with ui.tab_panel(tab_inspect).classes('w-full'):
                with ui.row().classes('items-center justify-between mb-4'):
                    with ui.row().classes('items-center gap-4'):
                        ui.label('Population State Inspection').classes('text-xl font-bold text-gray-700')
                        ui.number(label='Go to Tick', value=None, min=0, precision=0, on_change=self.handle_tick_input).props('dense outlined').classes('w-32')  # type: ignore[reportUnknownMemberType]
                    self.lbl_status_mode = ui.label('LIVE VIEW').classes('font-bold text-green-600 text-lg')

                with ui.row().classes('w-full gap-12 items-start no-wrap'):
                    with ui.column().classes('w-[26rem] shrink-0 gap-3'):
                        with ui.card().classes('p-3 border rounded shadow-sm w-full'):
                            ui.label('Count Summary by Sex').classes('text-lg font-bold text-gray-700 mb-1')
                            self.summary_sex_container = ui.column().classes('w-full gap-1')
                        with ui.card().classes('p-3 border rounded shadow-sm w-full') as self.age_summary_card:
                            ui.label('Count Summary by Age').classes('text-lg font-bold text-gray-700 mb-1')
                            self.summary_age_container = ui.column().classes('w-full gap-1')
                            self.age_summary_card.visible = self._is_age_structured_population

                    # Container for Genotype Cards (right side)
                    with ui.column().classes('flex-1 min-w-0'):
                        self.genotype_container = ui.row().classes('w-full flex-wrap gap-4')



            # --- Tab 2: Configuration ---
            with ui.tab_panel(tab_config):
                with ui.row().classes('w-full gap-8'):
                    # Scalar Configs
                    with ui.column().classes('w-1/3'):
                        ui.label('Parameters').classes('text-xl font-bold text-gray-700')
                        conf = self.pop._config
                        ui.label(f"Carrying Capacity: {conf.carrying_capacity}").classes('text-base')
                        ui.label(f"Eggs/Female: {conf.eggs_per_female}").classes('text-base')
                        mode_code = int(conf.juvenile_growth_mode)
                        ui.label(f"Growth Mode: {mode_code} ({self._growth_mode_name(mode_code)})").classes('text-base')
                        ui.label(f"Stochastic: {conf.stochastic}").classes('text-base')

                    # Fitness Tables
                    with ui.column().classes('flex-grow'):
                        with ui.expansion('Fitness Tables', icon='fitness_center').classes('w-full border rounded mb-2'):
                            with ui.column().classes('p-2 w-full'):
                                ui.label('Viability Fitness').classes('font-bold text-gray-700 text-lg')
                                ui.table(columns=[
                                    {'name': 'Genotype', 'label': 'Genotype', 'field': 'Genotype'},
                                    {'name': 'Female', 'label': 'Female', 'field': 'Female'},
                                    {'name': 'Male', 'label': 'Male', 'field': 'Male'},
                                ], rows=self._get_viability_data()).props('dense flat').classes('mb-4 w-full')

                                ui.label('Fecundity Fitness').classes('font-bold text-gray-700 text-lg')
                                ui.table(columns=[
                                    {'name': 'Genotype', 'label': 'Genotype', 'field': 'Genotype'},
                                    {'name': 'Female', 'label': 'Female', 'field': 'Female'},
                                    {'name': 'Male', 'label': 'Male', 'field': 'Male'},
                                ], rows=self._get_fecundity_data()).props('dense flat').classes('w-full')

            # --- Tab 3: Hooks ---
            with ui.tab_panel(tab_hooks):
                render_hooks_panel(self.pop)

            # --- Tab 4: Observation ---
            with ui.tab_panel(tab_observation):
                self.obs_panel.build(ui.column())

            # --- Tab 5: Genetics ---
            with ui.tab_panel(tab_rules):
                with ui.column().classes('w-full gap-6'):
                    ui.label('Meiosis (Genotype -> Gametes)').classes('font-bold text-gray-700 text-xl')
                    figs = self._create_meiosis_plots()  # type: ignore[reportUnknownVariableType]
                    with ui.row().classes('w-full gap-4'):
                        for fig in figs:  # type: ignore[reportUnknownVariableType]
                            ui.plotly(fig).classes('flex-1 h-[600px] border rounded')  # type: ignore[reportUnknownArgumentType]

                    ui.label('Fertilization (Gametes -> Zygote)').classes('font-bold text-gray-700 text-xl mt-4')
                    fig_fert = self._create_fertilization_plot()
                    if fig_fert:
                        ui.plotly(fig_fert).classes('w-full border rounded').props('style="height: 600px;"')
                    else:
                        ui.label("Fertilization matrix too large to display.").classes('text-orange-500 italic')

    # Timer for loop
    self._tick_timer = ui.timer(0.1, self._on_timer)

    # Initial refresh to show state 0
    self.refresh_ui()
reset_zoom
reset_zoom()

Reset zoom on both charts.

Source code in src/natal/ui/dashboard_population.py
def reset_zoom(self):
    """Reset zoom on both charts."""
    if self.view_min is None and self.view_max is None:
        return # No zoom to reset
    self.view_min = None
    self.view_max = None
    self._update_charts()
    self.chart_pop.run_method('zoomOut')
    self.chart_allele.run_method('zoomOut')

SpatialDashboard

SpatialDashboard(population: SpatialPopulation)

Real-time dashboard for a SpatialPopulation.

Initialize the spatial dashboard.

Source code in src/natal/ui/spatial_dashboard.py
def __init__(self, population: SpatialPopulation):
    """Initialize the spatial dashboard."""
    if not _has_nicegui:
        raise ImportError("NiceGUI is required. Please install it with: pip install nicegui")

    self.pop = population
    self.is_running = False
    self.is_processing = False
    self._tick_timer = None

    self.selected_deme_idx = 0
    self._last_chart_tick = -1
    self._chart_history: list[list[float]] = []
    self._allele_freq_history: dict[str, list[list[float]]] = {}
    self.show_numbers = False  # Toggle for displaying population counts as numbers
    self._max_count_history = 0.0  # Track historical maximum for colorbar range

    # Landscape cache for incremental (no-flicker) updates
    self._landscape_plot: Any = None
    self._landscape_fig: Any = None
    self._landscape_state: dict[str, Any] = {}  # structural hash

    # Observation panel (reusable component)
    # Wrap aggregate count in an object so render_observation_results can
    # detect it as raw state (not pre-applied observed data).
    class _StateRef:
        def __init__(self, pop):  # type: ignore[reportMissingParameterType, reportUnknownParameterType]
            """Initialize the state reference wrapper.

            Args:
                pop: The spatial population to wrap for aggregate
                    state access.
            """
            self._pop = pop

        @property
        def individual_count(self):  # type: ignore[reportUnknownMemberType]
            return self._pop.aggregate_individual_count()  # type: ignore[reportPossiblyUnboundVariable, reportUnknownVariableType]

    self.obs_panel = ObservationPanel(
        genotype_labels=get_unordered_genotype_labels(
            self.pop.deme(0).registry.index_to_genotype
        ),
        get_state=lambda: _StateRef(self.pop),
        get_registry=lambda: self.pop.deme(0).registry,
    )

    self._rebuild_chart_history()
    self._record_snapshot()
refresh_ui
refresh_ui() -> None

Refresh all reactive UI elements.

Source code in src/natal/ui/spatial_dashboard.py
def refresh_ui(self) -> None:
    """Refresh all reactive UI elements."""
    self._record_snapshot()
    self._update_global_stats()
    self._update_charts()
    self._render_landscape()
    self._render_migration_panel()
    self._update_selected_deme()
show_export_dialog
show_export_dialog() -> None

Open export dialog for spatial data.

Source code in src/natal/ui/spatial_dashboard.py
def show_export_dialog(self) -> None:
    """Open export dialog for spatial data."""
    with ui.dialog() as self.export_dialog, ui.card():  # type: ignore[reportPossiblyUnboundVariable]
        ui.label("Select items to export").classes("text-lg font-bold")  # type: ignore[reportPossiblyUnboundVariable]
        self.cb_config = ui.checkbox("Configuration & Fitness", value=True)  # type: ignore[reportPossiblyUnboundVariable]
        self.cb_history = ui.checkbox("Population History", value=True)  # type: ignore[reportPossiblyUnboundVariable]
        self.cb_hooks = ui.checkbox("Hooks", value=True)  # type: ignore[reportPossiblyUnboundVariable]
        with ui.row().classes("w-full justify-end gap-2 mt-4"):  # type: ignore[reportPossiblyUnboundVariable]
            ui.button("Export", on_click=self._do_export)  # type: ignore[reportPossiblyUnboundVariable]
            ui.button("Cancel", on_click=self.export_dialog.close).props("flat")  # type: ignore[reportPossiblyUnboundVariable]
    self.export_dialog.open()
reset_simulation
reset_simulation() -> None

Reset the spatial simulation and the dashboard session history.

Source code in src/natal/ui/spatial_dashboard.py
def reset_simulation(self) -> None:
    """Reset the spatial simulation and the dashboard session history."""
    self.pop.reset()
    self.is_running = False
    self._chart_history = []
    self._allele_freq_history = {}
    self._last_chart_tick = -1
    self._record_snapshot()

    self.btn_play.props("icon=play_arrow")
    self.btn_play.text = "Play"
    self.status_label.text = "Ready"
    self.refresh_ui()
    ui.notify("Spatial population reset.")  # type: ignore[reportPossiblyUnboundVariable]
build_layout
build_layout() -> None

Construct the NiceGUI layout.

Source code in src/natal/ui/spatial_dashboard.py
def build_layout(self) -> None:
    """Construct the NiceGUI layout."""
    with ui.header().classes("items-center justify-between bg-slate-900 text-white"):  # type: ignore[reportPossiblyUnboundVariable]
        ui.label("Spatial NATAL Dashboard").classes("text-2xl font-bold")  # type: ignore[reportPossiblyUnboundVariable]
        ui.label(f"Population: {self.pop.name}").classes("text-base opacity-80")  # type: ignore[reportPossiblyUnboundVariable]

    with ui.left_drawer(value=True).classes("bg-gray-50 p-4 shadow-lg border-r").props("width=320"):  # type: ignore[reportPossiblyUnboundVariable]
        ui.label("Control Panel").classes("text-xl font-bold text-gray-700 mb-4")  # type: ignore[reportPossiblyUnboundVariable]

        with ui.row().classes("items-center gap-2 mb-4 p-2 bg-white rounded border"):  # type: ignore[reportPossiblyUnboundVariable]
            self.status_spinner = ui.spinner(size="sm").props("color=primary")  # type: ignore[reportPossiblyUnboundVariable]
            self.status_label = ui.label("Ready").classes("text-base font-medium text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.status_spinner.visible = False

        ui.label("Global State").classes("text-sm font-bold text-gray-400 uppercase mb-2")  # type: ignore[reportPossiblyUnboundVariable]
        with ui.grid(columns=2).classes("w-full gap-y-2 gap-x-4 mb-4"):  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Tick").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_tick = ui.label(str(self.pop.tick)).classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Total").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_total = ui.label(str(self.pop.get_total_count())).classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Females").classes("font-bold text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_females = ui.label(str(self.pop.get_female_count())).classes("text-right font-mono text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Males").classes("font-bold text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_males = ui.label(str(self.pop.get_male_count())).classes("text-right font-mono text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Migration").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_mode = ui.label(self.pop.migration_mode).classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]

        ui.label("Landscape Display").classes("text-sm font-bold text-gray-400 uppercase mt-4 mb-2")  # type: ignore[reportPossiblyUnboundVariable]
        self.chk_show_numbers = ui.checkbox(  # type: ignore[reportPossiblyUnboundVariable]
            "Show numbers",
            value=False,
            on_change=lambda: self._on_show_numbers_change()
        ).classes("text-sm")
        if self._use_large_landscape_mode():
            self.chk_show_numbers.disable()

        ui.label("Interval (s) (0=Unlimited)").classes("text-sm font-bold text-gray-400 uppercase mt-4 mb-2")  # type: ignore[reportPossiblyUnboundVariable]
        self.slider_speed = ui.slider(min=0.0, max=0.2, value=0.05, step=0.005).props("label-always")  # type: ignore[reportPossiblyUnboundVariable]
        self.slider_speed.on_value_change(self._update_timer_interval)

        with ui.column().classes("w-full gap-2 mt-4"):  # type: ignore[reportPossiblyUnboundVariable]
            with ui.row().classes("w-full gap-2"):  # type: ignore[reportPossiblyUnboundVariable]
                ui.button("Step", on_click=self._run_step).props("icon=skip_next outline").classes("flex-grow")  # type: ignore[reportPossiblyUnboundVariable]

                def update_play_state(event: Any) -> None:  # plotly event object
                    self._toggle_play()
                    icon = "pause" if self.is_running else "play_arrow"
                    text = "Pause" if self.is_running else "Play"
                    event.sender.props(f"icon={icon}")
                    event.sender.text = text

                self.btn_play = ui.button("Play", on_click=update_play_state).props("icon=play_arrow").classes("flex-grow")  # type: ignore[reportPossiblyUnboundVariable]

            with ui.row().classes("w-full gap-2"):  # type: ignore[reportPossiblyUnboundVariable]
                ui.button("Reset", on_click=self.reset_simulation).props("icon=restart_alt flat color=grey").classes("flex-grow")  # type: ignore[reportPossiblyUnboundVariable]
                ui.button("Export", on_click=self.show_export_dialog).props("icon=download flat color=grey").classes("flex-grow")  # type: ignore[reportPossiblyUnboundVariable]

        self.lbl_history_count = ui.label("").classes("text-sm text-gray-400 italic mt-4")  # type: ignore[reportPossiblyUnboundVariable]
        ui.separator().classes("my-4")  # type: ignore[reportPossiblyUnboundVariable]

        ui.label("Selected Deme").classes("text-sm font-bold text-gray-400 uppercase mb-2")  # type: ignore[reportPossiblyUnboundVariable]
        with ui.grid(columns=2).classes("w-full gap-y-2 gap-x-4"):  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Name").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_selected_name = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Coord").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_selected_coord = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Total").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_selected_total = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Females").classes("font-bold text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_selected_females = ui.label("").classes("text-right font-mono text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
            ui.label("Males").classes("font-bold text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]
            self.lbl_selected_males = ui.label("").classes("text-right font-mono text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]

    with ui.column().classes("w-full p-4 gap-6"):  # type: ignore[reportPossiblyUnboundVariable]
        with ui.card().classes("w-full p-0 gap-0 border-none shadow-sm"):  # type: ignore[reportPossiblyUnboundVariable]
            with ui.row().classes("w-full no-wrap"):  # type: ignore[reportPossiblyUnboundVariable]
                self.chart_pop = ui.highchart(  # type: ignore[reportPossiblyUnboundVariable]
                    {
                        "title": {"text": "Total Population"},
                        "chart": {"type": "line", "animation": False, "height": 300},
                        "xAxis": {"title": {"text": "Tick"}},
                        "yAxis": {
                            "title": {"text": "Count"},
                            "allowDecimals": not self._discrete_display(),
                        },
                        "series": [{"name": "TotalPop", "data": []}],
                        "plotOptions": {"series": {"marker": {"enabled": False}}},
                    }
                ).classes("w-1/2 h-80")

                self.chart_allele = ui.highchart(  # type: ignore[reportPossiblyUnboundVariable]
                    {
                        "title": {"text": "Global Allele Frequencies"},
                        "chart": {"type": "line", "animation": False, "height": 300},
                        "xAxis": {"title": {"text": "Tick"}},
                        "yAxis": {"title": {"text": "Freq"}, "min": 0.0, "max": 1.0},
                        "series": [],
                        "plotOptions": {"series": {"marker": {"enabled": False}}},
                    }
                ).classes("w-1/2 h-80")

        with ui.tabs().classes("w-full justify-start border-b") as tabs:  # type: ignore[reportPossiblyUnboundVariable]
            self.tabs_main = tabs
            tab_overview = ui.tab(name="overview", label="Landscape", icon="grid_view")  # type: ignore[reportPossiblyUnboundVariable]
            tab_deme = ui.tab(name="deme", label="Selected Deme", icon="search")  # type: ignore[reportPossiblyUnboundVariable]
            tab_config = ui.tab(name="config", label="Config", icon="settings")  # type: ignore[reportPossiblyUnboundVariable]
            tab_hooks = ui.tab(name="hooks", label="Hooks", icon="extension")  # type: ignore[reportPossiblyUnboundVariable]
            tab_genetics = ui.tab(name="genetics", label="Genetics", icon="biotech")  # type: ignore[reportPossiblyUnboundVariable]
            tab_observation = ui.tab(name="observation", label="Observation", icon="visibility")  # type: ignore[reportPossiblyUnboundVariable]

        with ui.tab_panels(tabs, value="overview").classes("w-full bg-transparent p-0"):  # type: ignore[reportPossiblyUnboundVariable]
            with ui.tab_panel(tab_overview).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                with ui.row().classes("w-full gap-6 items-start"):  # type: ignore[reportPossiblyUnboundVariable]
                    with ui.card().classes("flex-1 min-w-0 p-4 border rounded shadow-sm"):  # type: ignore[reportPossiblyUnboundVariable]
                        with ui.row().classes("items-center gap-4 mb-3"):  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Landscape").classes("text-lg font-bold text-gray-700")  # type: ignore[reportPossiblyUnboundVariable]
                            self.landscape_metric = ui.select(  # type: ignore[reportPossiblyUnboundVariable]
                                label="Metric",
                                options={
                                    "total": "Total Population",
                                    "genotype": "Genotype Frequency",
                                    "allele": "Allele Frequency",
                                },
                                value="total",
                                on_change=self._on_landscape_metric_change,
                            ).classes("w-48")
                            self.landscape_target = ui.select(  # type: ignore[reportPossiblyUnboundVariable]
                                label="Target",
                                options={},
                                value=None,
                                on_change=self._on_landscape_metric_change,
                                new_value_mode="add",
                                key_generator=lambda x: x,
                            ).classes("w-48")
                            self.landscape_target.visible = False
                        self.landscape_container = ui.column().classes("w-full min-w-0 overflow-hidden gap-3")  # type: ignore[reportPossiblyUnboundVariable]
                    with ui.card().classes("w-[26rem] p-4 border rounded shadow-sm"):  # type: ignore[reportPossiblyUnboundVariable]
                        ui.label("Migration Rule").classes("text-lg font-bold text-gray-700 mb-3")  # type: ignore[reportPossiblyUnboundVariable]
                        self.migration_container = ui.column().classes("w-full gap-2")  # type: ignore[reportPossiblyUnboundVariable]

            with ui.tab_panel(tab_deme).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                ui.label("Selected Deme State").classes("text-xl font-bold text-gray-700 mb-4")  # type: ignore[reportPossiblyUnboundVariable]
                with ui.row().classes("w-full gap-6 items-start"):  # type: ignore[reportPossiblyUnboundVariable]
                    with ui.card().classes("w-[22rem] p-4 border rounded shadow-sm"):  # type: ignore[reportPossiblyUnboundVariable]
                        ui.label("Overview").classes("text-lg font-bold text-gray-700 mb-3")  # type: ignore[reportPossiblyUnboundVariable]
                        with ui.grid(columns=2).classes("w-full gap-y-2 gap-x-4"):  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Name").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
                            self.lbl_selected_name_detail = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Coord").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
                            self.lbl_selected_coord_detail = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Total").classes("font-bold text-gray-600")  # type: ignore[reportPossiblyUnboundVariable]
                            self.lbl_selected_total_detail = ui.label("").classes("text-right font-mono")  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Females").classes("font-bold text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
                            self.lbl_selected_females_detail = ui.label("").classes("text-right font-mono text-pink-600")  # type: ignore[reportPossiblyUnboundVariable]
                            ui.label("Males").classes("font-bold text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]
                            self.lbl_selected_males_detail = ui.label("").classes("text-right font-mono text-blue-600")  # type: ignore[reportPossiblyUnboundVariable]

                    self.age_summary_card = ui.card().classes("flex-1 p-4 border rounded shadow-sm")  # type: ignore[reportPossiblyUnboundVariable]
                    with self.age_summary_card:
                        ui.label("Age Breakdown").classes("text-lg font-bold text-gray-700 mb-3")  # type: ignore[reportPossiblyUnboundVariable]
                        self.summary_age_container = ui.column().classes("w-full gap-0")  # type: ignore[reportPossiblyUnboundVariable]

                with ui.card().classes("w-full p-4 border rounded shadow-sm mt-4"):  # type: ignore[reportPossiblyUnboundVariable]
                    ui.label("Genotype Details").classes("text-lg font-bold text-gray-700 mb-3")  # type: ignore[reportPossiblyUnboundVariable]
                    self.genotype_container = ui.row().classes("w-full flex-wrap gap-4")  # type: ignore[reportPossiblyUnboundVariable]

            with ui.tab_panel(tab_config).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                self.config_container = ui.column().classes("w-full gap-6")  # type: ignore[reportPossiblyUnboundVariable]
                self._render_deme_config()

            with ui.tab_panel(tab_hooks).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                self.hooks_container = ui.column().classes("w-full gap-4")  # type: ignore[reportPossiblyUnboundVariable]
                self._render_hooks_panel()

            with ui.tab_panel(tab_genetics).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                with ui.column().classes("w-full gap-6"):  # type: ignore[reportPossiblyUnboundVariable]
                    ui.label("Meiosis (Genotype → Gametes)").classes("font-bold text-gray-700 text-xl")  # type: ignore[reportPossiblyUnboundVariable]
                    figs = self._create_meiosis_plots()  # type: ignore[reportUnknownVariableType]
                    with ui.row().classes("w-full gap-4"):  # type: ignore[reportUnknownVariableType]
                        for fig in figs:  # type: ignore[reportPossiblyUnboundVariable]
                            ui.plotly(fig).classes("flex-1 h-[600px] border rounded")  # type: ignore[reportUnknownArgumentType]

                    ui.label("Fertilization (Gametes → Zygote)").classes("font-bold text-gray-700 text-xl mt-4")  # type: ignore[reportPossiblyUnboundVariable]
                    fig_fert = self._create_fertilization_plot()
                    if fig_fert:
                        ui.plotly(fig_fert).classes("w-full border rounded").props('style="height: 600px;"')  # type: ignore[reportPossiblyUnboundVariable]
                    else:
                        ui.label("Fertilization matrix too large to display.").classes("text-orange-500 italic")  # type: ignore[reportPossiblyUnboundVariable]

            with ui.tab_panel(tab_observation).classes("w-full"):  # type: ignore[reportPossiblyUnboundVariable]
                self.obs_panel.build(ui.column())  # type: ignore[reportPossiblyUnboundVariable]

    self._tick_timer = ui.timer(0.1, self._on_timer)  # type: ignore[reportPossiblyUnboundVariable]
    self.refresh_ui()

launch

launch(population: Any, port: int = 8080, title: str = 'NATAL Dashboard') -> None

Launch either the population or spatial dashboard.

Source code in src/natal/ui/dashboard.py
def launch(population: Any, port: int = 8080, title: str = "NATAL Dashboard") -> None:  # population parameter; generic entry point
    """Launch either the population or spatial dashboard."""
    if isinstance(population, SpatialPopulation):
        launch_spatial(population, port=port, title=title)
        return
    launch_population(population, port=port, title=title)

launch_population

launch_population(population: BasePopulation[Any], port: int = 8080, title: str = 'NATAL Dashboard')

Launch the embedded dashboard.

Parameters:

Name Type Description Default
population BasePopulation[Any]

The population object to visualize.

required
port int

Web server port.

8080
title str

The title of the dashboard.

'NATAL Dashboard'
Source code in src/natal/ui/dashboard_population.py
def launch(population: 'BasePopulation[Any]', port: int = 8080, title: str = "NATAL Dashboard"):
    """
    Launch the embedded dashboard.

    Args:
        population: The population object to visualize.
        port: Web server port.
        title: The title of the dashboard.
    """
    from importlib.resources import files

    # Get favicon path from package resources
    favicon_path = str(files('natal').joinpath('natal.svg'))

    # Reset NiceGUI state to avoid conflicts if re-run in same process
    # Note: NiceGUI is singleton-based, so multiple launches might need care.

    @ui.page('/')
    def main_page():  # type: ignore[reportUnusedFunction]
        dashboard = Dashboard(population)
        dashboard.build_layout()

    # Start server
    # In a script usage, we typically want this to block so the script keeps running
    # and serving the UI.
    print(f"🚀 Starting Dashboard at http://localhost:{port}")
    print("📖 Click Ctrl+C to stop the dashboard")
    title = f"{population.name} - NATAL Dashboard" if population.name else "NATAL Dashboard"
    ui.run(title=title, port=port, show=False, reload=False, favicon=favicon_path)  # type: ignore[reportUnknownMemberType]

launch_spatial

launch_spatial(population: SpatialPopulation, port: int = 8080, title: str = 'NATAL Spatial Dashboard') -> None

Launch the embedded spatial dashboard.

Source code in src/natal/ui/spatial_dashboard.py
def launch_spatial(
    population: SpatialPopulation,
    port: int = 8080,
    title: str = "NATAL Spatial Dashboard",
) -> None:
    """Launch the embedded spatial dashboard."""
    from importlib.resources import files

    # Get favicon path from package resources
    favicon_path = str(files('natal').joinpath('natal.svg'))

    @ui.page("/")  # type: ignore[reportUnusedFunction]
    def main_page() -> None:  # type: ignore[reportUnusedFunction]
        dashboard = SpatialDashboard(population)
        dashboard.build_layout()

    print(f"Starting Spatial Dashboard at http://localhost:{port}")
    ui.run(title=title, port=port, show=False, reload=False, favicon=favicon_path)  # type: ignore[reportUnknownMemberType]

get_allele_color

get_allele_color(allele_name: str) -> str

Determine a display color for an allele based on naming conventions.

Parameters:

Name Type Description Default
allele_name str

The name of the allele.

required

Returns:

Type Description
str

Hex color string (e.g., "#ff0000").

Source code in src/natal/ui/visualization.py
def get_allele_color(allele_name: str) -> str:
    """Determine a display color for an allele based on naming conventions.

    Args:
        allele_name: The name of the allele.

    Returns:
        Hex color string (e.g., "#ff0000").
    """
    name = allele_name.lower()
    # Default color scheme
    if "wt" in name or "+" in name or "wild" in name:
        return "#3b82f6"  # Blue (WT)
    if "drive" in name or "dr" in name:
        return "#ef4444"  # Red (Drive)
    if "r1" in name or "functional" in name:
        return "#a855f7"  # Purple (Functional R1)
    if "r2" in name or "resistance" in name:
        return "#eab308"  # Yellow (R2 / Resistance)
    if "rescue" in name:
        return "#22c55e"  # Green (Rescue)

    # Fallback: deterministic random color based on name hash
    h = hashlib.md5(allele_name.encode('utf-8')).hexdigest()
    return f"#{h[:6]}"

render_cell_svg

render_cell_svg(entity: Any, species_def: Species, size: int = 100) -> str

Generate an SVG string representing a cell's genotype.

Draws a cell circle containing chromosome bars. Can render both diploid Genotypes and HaploidGenotypes.

Parameters:

Name Type Description Default
entity Any

Genotype or HaploidGenotype instance.

required
species_def Species

Species instance defining the chromosome structure.

required
size int

Width/Height of the SVG in pixels.

100

Returns:

Type Description
str

String containing the SVG XML.

Source code in src/natal/ui/visualization.py
def render_cell_svg(entity: Any, species_def: "Species", size: int = 100) -> str:  # entity: duck-typed for Genotype and HaploidGenotype
    """Generate an SVG string representing a cell's genotype.

    Draws a cell circle containing chromosome bars. Can render both diploid 
    Genotypes and HaploidGenotypes.

    Args:
        entity: Genotype or HaploidGenotype instance.
        species_def: Species instance defining the chromosome structure.
        size: Width/Height of the SVG in pixels.

    Returns:
        String containing the SVG XML.
    """
    # Determine ploidy based on attributes
    is_diploid = hasattr(entity, 'maternal') and hasattr(entity, 'paternal')

    chromosomes = species_def.chromosomes
    n_chroms = len(chromosomes)

    # SVG container and cell membrane
    svg = [f'<svg width="{size}" height="{size}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">']
    svg.append('<circle cx="50" cy="50" r="48" fill="#f8fafc" stroke="#334155" stroke-width="2"/>')

    # Layout calculations
    padding_x = 20
    avail_width = 100 - 2 * padding_x
    col_width = avail_width / max(1, n_chroms)

    bar_width = 6
    bar_height = 50
    bar_y_start = (100 - bar_height) / 2

    for i, chrom in enumerate(chromosomes):
        cx = padding_x + i * col_width + col_width / 2
        loci = chrom.loci
        n_loci = len(loci)
        seg_height = bar_height / max(1, n_loci)

        def draw_chrom_bar(x: float, source_obj: "HaploidGenotype | None") -> None:
            # Get haplotype for this chromosome
            if source_obj is None: # Missing chromosome
                return

            # Try to get haplotype (works for both Genotype via helper or HaploidGenotype direct access)
            # For Genotype, source_obj is a HaploidGenotype
            # For HaploidGenotype, source_obj is self
            haplo = source_obj.get_haplotype_for_chromosome(chrom)
            if haplo is None:  # type: ignore[comparison-overlap]
                return

            for l_idx, locus in enumerate(loci):
                gene = haplo.get_gene_at_locus(locus)
                color = get_allele_color(gene.name) if gene else "#cbd5e1"

                y = bar_y_start + l_idx * seg_height
                # Draw segment (rounded if single, or ends)
                # Simplified rounding for visual cleanliness
                radius = 3 if n_loci == 1 else 1
                svg.append(f'<rect x="{x - bar_width/2}" y="{y}" width="{bar_width}" height="{seg_height}" '
                           f'fill="{color}" rx="{radius}" stroke="none"/>')
                # Separator line between loci
                if l_idx > 0:
                    svg.append(f'<line x1="{x-bar_width/2}" y1="{y}" x2="{x+bar_width/2}" y2="{y}" stroke="white" stroke-width="1"/>')

        if is_diploid:
            draw_chrom_bar(cx - 5, entity.maternal) # Maternal (Left)
            draw_chrom_bar(cx + 5, entity.paternal) # Paternal (Right)
        else:
            draw_chrom_bar(cx, entity) # Haploid (Center)

    svg.append('</svg>')
    return "".join(svg)