diff --git a/crates/re_space_view_time_series/src/space_view_class.rs b/crates/re_space_view_time_series/src/space_view_class.rs index b77b87db0524..8df51c8066ac 100644 --- a/crates/re_space_view_time_series/src/space_view_class.rs +++ b/crates/re_space_view_time_series/src/space_view_class.rs @@ -404,6 +404,7 @@ It can greatly improve performance (and readability) in such situations as it pr time_zone_for_timestamps, ) }) + .y_axis_width(3) // in digits .label_formatter(move |name, value| { let name = if name.is_empty() { "y" } else { name }; let label = time_type.format( diff --git a/examples/python/blueprint_stocks/README.md b/examples/python/blueprint_stocks/README.md new file mode 100644 index 000000000000..7e8ea96d1ff8 --- /dev/null +++ b/examples/python/blueprint_stocks/README.md @@ -0,0 +1,38 @@ + + + + + + + + + + +This example fetches the last 5 days of stock data for a few different stocks. +We show how Rerun blueprints can then be used to present many different views of the same data. + +```bash +pip install -r examples/python/blueprint_stocks/requirements.txt +python examples/python/blueprint_stocks/blueprint_main.py +``` + +The different blueprints can be explored using the `--blueprint` flag. For example: + +``` +python examples/python/blueprint_stocks/main.py --blueprint=one-stock +``` + +Available choices are: + +- `auto`: Reset the blueprint to the auto layout used by the viewer. +- `one-stock`: Uses a filter to show only a single chart. +- `one-stock-with-info`: Uses a container to layout a chart and its info document +- `one-stock-no-peaks`: Uses a filter to additionally remove some of the data from the chart. +- `compare-two`: Adds data from multiple sources to a single chart. +- `grid`: Shows all the charts in a grid layout. diff --git a/examples/python/blueprint_stocks/main.py b/examples/python/blueprint_stocks/main.py new file mode 100755 index 000000000000..e847587f7038 --- /dev/null +++ b/examples/python/blueprint_stocks/main.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +A simple application that fetches stock data from Yahoo Finance and visualizes it using the Rerun SDK. + +The main focus of this example is using blueprints to control how the data is displayed in the viewer. +""" +from __future__ import annotations + +import argparse +import datetime as dt +from typing import Any + +import humanize +import pytz +import rerun as rr +import rerun.blueprint as rrb +import yfinance as yf + +################################################################################ +# Helper functions to create blueprints +################################################################################ + + +def auto_blueprint() -> rrb.ViewportLike: + """A blueprint enabling auto space views, which matches the application default.""" + return rrb.Viewport(auto_space_views=True, auto_layout=True) + + +def one_stock(symbol: str) -> rrb.ViewportLike: + """Create a blueprint showing a single stock.""" + return rrb.TimeSeriesView(name=f"{symbol}", origin=f"/stocks/{symbol}") + + +def one_stock_with_info(symbol: str) -> rrb.ViewportLike: + """Create a blueprint showing a single stock with its info arranged vertically.""" + return rrb.Vertical( + rrb.TextDocumentView(name=f"{symbol}", origin=f"/stocks/{symbol}/info"), + rrb.TimeSeriesView(name=f"{symbol}", origin=f"/stocks/{symbol}"), + row_shares=[1, 4], + ) + + +def compare_two(symbol1: str, symbol2: str, day: Any) -> rrb.ViewportLike: + """Create a blueprint comparing 2 stocks for a single day.""" + return rrb.TimeSeriesView( + name=f"{symbol1} vs {symbol2} ({day})", + contents=[ + f"+ /stocks/{symbol1}/{day}", + f"+ /stocks/{symbol2}/{day}", + ], + ) + + +def one_stock_no_peaks(symbol: str) -> rrb.ViewportLike: + """ + Create a blueprint showing a single stock without annotated peaks. + + This uses an exclusion pattern to hide the peaks. + """ + return rrb.TimeSeriesView( + name=f"{symbol}", + origin=f"/stocks/{symbol}", + contents=[ + "+ $origin/**", + "- $origin/peaks/**", + ], + ) + + +def stock_grid(symbols: list[str], dates: list[Any]) -> rrb.ViewportLike: + """Create a grid of stocks and their time series over all days.""" + return rrb.Vertical( + contents=[ + rrb.Horizontal( + contents=[rrb.TextDocumentView(name=f"{symbol}", origin=f"/stocks/{symbol}/info")] + + [rrb.TimeSeriesView(name=f"{day}", origin=f"/stocks/{symbol}/{day}") for day in dates], + ) + for symbol in symbols + ] + ) + + +def hide_panels(viewport: rrb.ViewportLike) -> rrb.BlueprintLike: + """Wrap a viewport in a blueprint that hides the time and selection panels.""" + return rrb.Blueprint( + viewport, + rrb.TimePanel(expanded=False), + rrb.SelectionPanel(expanded=False), + ) + + +################################################################################ +# Helper functions for styling +################################################################################ + +brand_colors = { + "AAPL": 0xA2AAADFF, + "AMZN": 0xFF9900FF, + "GOOGL": 0x34A853FF, + "META": 0x0081FBFF, + "MSFT": 0xF14F21FF, +} + + +def style_plot(symbol: str) -> rr.SeriesLine: + return rr.SeriesLine( + color=brand_colors[symbol], + name=symbol, + ) + + +def style_peak(symbol: str) -> rr.SeriesPoint: + return rr.SeriesPoint( + color=0xFF0000FF, + name=f"{symbol} (peak)", + marker="Up", + ) + + +################################################################################ +# Main script +################################################################################ + + +def main() -> None: + parser = argparse.ArgumentParser(description="Visualize stock data using the Rerun SDK") + parser.add_argument( + "--blueprint", + choices=["auto", "one-stock", "one-stock-with-info", "compare-two", "one-stock-no-peaks", "grid"], + default="grid", + help="Select the blueprint to use", + ) + parser.add_argument( + "--show_panels", + action="store_true", + help="Show the time and selection panels", + ) + + rr.script_add_args(parser) + args = parser.parse_args() + + et_timezone = pytz.timezone("America/New_York") + current_date = dt.datetime.now(et_timezone).date() + symbols = ["AAPL", "AMZN", "GOOGL", "META", "MSFT"] + dates = list(filter(lambda x: x.weekday() < 5, [current_date - dt.timedelta(days=i) for i in range(7, 0, -1)])) + + blueprint: rrb.BlueprintLike + + if args.blueprint == "auto": + blueprint = auto_blueprint() + elif args.blueprint == "one-stock": + blueprint = one_stock("AAPL") + elif args.blueprint == "one-stock-with-info": + blueprint = one_stock_with_info("AMZN") + elif args.blueprint == "one-stock-no-peaks": + blueprint = one_stock_no_peaks("GOOGL") + elif args.blueprint == "compare-two": + blueprint = compare_two("META", "MSFT", dates[-1]) + elif args.blueprint == "grid": + blueprint = stock_grid(symbols, dates) + else: + raise ValueError(f"Unknown blueprint: {args.blueprint}") + + if not args.show_panels: + blueprint = hide_panels(blueprint) + + rr.init("rerun_example_blueprint_stocks", spawn=True, blueprint=blueprint) + + # In a future blueprint release, this can move into the blueprint as well + for symbol in symbols: + for day in dates: + rr.log(f"stocks/{symbol}/{day}", style_plot(symbol), timeless=True) + rr.log(f"stocks/{symbol}/peaks/{day}", style_peak(symbol), timeless=True) + + for symbol in symbols: + stock = yf.Ticker(symbol) + + name = stock.info["shortName"] + industry = stock.info["industry"] + marketCap = humanize.intword(stock.info["marketCap"]) + revenue = humanize.intword(stock.info["totalRevenue"]) + + info_md = ( + f"- **Name**: {name}\n" + f"- **Industry**: {industry}\n" + f"- **Market cap**: ${marketCap}\n" + f"- **Total Revenue**: ${revenue}\n" + ) + + rr.log( + f"stocks/{symbol}/info", + rr.TextDocument(info_md, media_type=rr.MediaType.MARKDOWN), + timeless=True, + ) + + for day in dates: + open_time = dt.datetime.combine(day, dt.time(9, 30)) + close_time = dt.datetime.combine(day, dt.time(16, 00)) + + hist = stock.history(start=open_time, end=close_time, interval="5m") + + hist.index = hist.index - et_timezone.localize(open_time) + peak = hist.High.idxmax() + + for row in hist.itertuples(): + rr.set_time_seconds("time", row.Index.total_seconds()) + rr.log(f"stocks/{symbol}/{day}", rr.Scalar(row.High)) + if row.Index == peak: + rr.log(f"stocks/{symbol}/peaks/{day}", rr.Scalar(row.High)) + + rr.script_teardown(args) + + +if __name__ == "__main__": + main() diff --git a/examples/python/blueprint_stocks/requirements.txt b/examples/python/blueprint_stocks/requirements.txt new file mode 100644 index 000000000000..25678ffa93ea --- /dev/null +++ b/examples/python/blueprint_stocks/requirements.txt @@ -0,0 +1,3 @@ +humanize +rerun-sdk +yfinance diff --git a/examples/python/requirements.txt b/examples/python/requirements.txt index c39318a01368..2d9899ef7976 100644 --- a/examples/python/requirements.txt +++ b/examples/python/requirements.txt @@ -1,5 +1,6 @@ -r arkit_scenes/requirements.txt -r blueprint/requirements.txt +-r blueprint_stocks/requirements.txt -r clock/requirements.txt -r controlnet/requirements.txt -r depth_guided_stable_diffusion/requirements.txt diff --git a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py index 0924e508e31a..f0c78a7630b8 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/__init__.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/__init__.py @@ -21,6 +21,7 @@ "TimeSeriesView", "Vertical", "Viewport", + "ViewportLike", ] from . import archetypes, components, datatypes @@ -31,6 +32,7 @@ SelectionPanel, TimePanel, Viewport, + ViewportLike, ) from .containers import Grid, Horizontal, Tabs, Vertical from .space_views import ( diff --git a/rerun_py/rerun_sdk/rerun/blueprint/api.py b/rerun_py/rerun_sdk/rerun/blueprint/api.py index bd7cc25fde72..4979406f4d87 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/api.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/api.py @@ -119,7 +119,8 @@ class Container: def __init__( self, - *contents: Container | SpaceView, + *args: Container | SpaceView, + contents: Optional[Iterable[Container | SpaceView]] = None, kind: ContainerKindLike, column_shares: Optional[ColumnShareArrayLike] = None, row_shares: Optional[RowShareArrayLike] = None, @@ -131,8 +132,11 @@ def __init__( Parameters ---------- - *contents: - All positional arguments are the contents of the container, which may be either other containers or space views. + *args: + All positional arguments are forwarded to the `contents` parameter for convenience. + contents: + The contents of the container. Each item in the iterable must be a `SpaceView` or a `Container`. + This can only be used if no positional arguments are provided. kind The kind of the container. This must correspond to a known container kind. Prefer to use one of the subclasses of `Container` which will populate this for you. @@ -150,9 +154,17 @@ def __init__( The active tab in the container. This is only applicable to `Tabs` containers. """ + + if args and contents is not None: + raise ValueError("Cannot provide both positional and keyword arguments for contents") + + if contents is not None: + self.contents = contents + else: + self.contents = args + self.id = uuid.uuid4() self.kind = kind - self.contents = contents self.column_shares = column_shares self.row_shares = row_shares self.grid_columns = grid_columns @@ -214,7 +226,11 @@ class Viewport: """ def __init__( - self, root_container: Container, *, auto_layout: bool | None = None, auto_space_views: bool | None = None + self, + root_container: Container | None = None, + *, + auto_layout: bool | None = None, + auto_space_views: bool | None = None, ): """ Construct a new viewport. @@ -255,11 +271,18 @@ def to_blueprint(self) -> Blueprint: def _log_to_stream(self, stream: RecordingStream) -> None: """Internal method to convert to an archetype and log to the stream.""" - self.root_container._log_to_stream(stream) + if self.root_container is not None: + self.root_container._log_to_stream(stream) + + root_container_id = self.root_container.id.bytes + space_views = list(self.root_container._iter_space_views()) + else: + root_container_id = None + space_views = [] arch = ViewportBlueprint( - space_views=list(self.root_container._iter_space_views()), - root_container=self.root_container.id.bytes, + space_views=space_views, + root_container=root_container_id, auto_layout=self.auto_layout, auto_space_views=self.auto_space_views, ) @@ -434,7 +457,7 @@ def _log_to_stream(self, stream: RecordingStream) -> None: self.time_panel._log_to_stream(stream) -BlueprintLike = Union[Blueprint, Viewport, Container, SpaceView] +BlueprintLike = Union[Blueprint, ViewportLike] """ A type that can be converted to a blueprint. diff --git a/rerun_py/rerun_sdk/rerun/blueprint/containers.py b/rerun_py/rerun_sdk/rerun/blueprint/containers.py index 40c6bc825fb5..b54a509ed36a 100644 --- a/rerun_py/rerun_sdk/rerun/blueprint/containers.py +++ b/rerun_py/rerun_sdk/rerun/blueprint/containers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional +from typing import Iterable, Optional from .api import Container, SpaceView from .components import ColumnShareArrayLike, RowShareArrayLike @@ -10,39 +10,55 @@ class Horizontal(Container): """A horizontal container.""" - def __init__(self, *contents: Container | SpaceView, column_shares: Optional[ColumnShareArrayLike] = None): + def __init__( + self, + *args: Container | SpaceView, + contents: Optional[Iterable[Container | SpaceView]] = None, + column_shares: Optional[ColumnShareArrayLike] = None, + ): """ Construct a new horizontal container. Parameters ---------- - *contents: - All positional arguments are the contents of the container, which may be either other containers or space views. + *args: + All positional arguments are forwarded to the `contents` parameter for convenience. + contents: + The contents of the container. Each item in the iterable must be a `SpaceView` or a `Container`. + This can only be used if no positional arguments are provided. column_shares The layout shares of the columns in the container. The share is used to determine what fraction of the total width each column should take up. The column with index `i` will take up the fraction `shares[i] / total_shares`. """ - super().__init__(*contents, kind=ContainerKind.Horizontal, column_shares=column_shares) + super().__init__(*args, contents=contents, kind=ContainerKind.Horizontal, column_shares=column_shares) class Vertical(Container): """A vertical container.""" - def __init__(self, *contents: Container | SpaceView, row_shares: Optional[RowShareArrayLike] = None): + def __init__( + self, + *args: Container | SpaceView, + contents: Optional[Iterable[Container | SpaceView]] = None, + row_shares: Optional[RowShareArrayLike] = None, + ): """ Construct a new vertical container. Parameters ---------- - *contents: - All positional arguments are the contents of the container, which may be either other containers or space views. + *args: + All positional arguments are forwarded to the `contents` parameter for convenience. + contents: + The contents of the container. Each item in the iterable must be a `SpaceView` or a `Container`. + This can only be used if no positional arguments are provided. row_shares The layout shares of the rows in the container. The share is used to determine what fraction of the total height each row should take up. The row with index `i` will take up the fraction `shares[i] / total_shares`. """ - super().__init__(*contents, kind=ContainerKind.Vertical, row_shares=row_shares) + super().__init__(*args, contents=contents, kind=ContainerKind.Vertical, row_shares=row_shares) class Grid(Container): @@ -50,7 +66,8 @@ class Grid(Container): def __init__( self, - *contents: Container | SpaceView, + *args: Container | SpaceView, + contents: Optional[Iterable[Container | SpaceView]] = None, column_shares: Optional[ColumnShareArrayLike] = None, row_shares: Optional[RowShareArrayLike] = None, grid_columns: Optional[int] = None, @@ -60,8 +77,11 @@ def __init__( Parameters ---------- - *contents: - All positional arguments are the contents of the container, which may be either other containers or space views. + *args: + All positional arguments are forwarded to the `contents` parameter for convenience. + contents: + The contents of the container. Each item in the iterable must be a `SpaceView` or a `Container`. + This can only be used if no positional arguments are provided. column_shares The layout shares of the columns in the container. The share is used to determine what fraction of the total width each column should take up. The column with index `i` will take up the fraction `shares[i] / total_shares`. @@ -73,7 +93,8 @@ def __init__( """ super().__init__( - *contents, + *args, + contents=contents, kind=ContainerKind.Grid, column_shares=column_shares, row_shares=row_shares, @@ -84,16 +105,24 @@ def __init__( class Tabs(Container): """A tab container.""" - def __init__(self, *contents: Container | SpaceView, active_tab: Optional[int | str] = None): + def __init__( + self, + *args: Container | SpaceView, + contents: Optional[Iterable[Container | SpaceView]] = None, + active_tab: Optional[int | str] = None, + ): """ Construct a new tab container. Parameters ---------- - *contents: - All positional arguments are the contents of the container, which may be either other containers or space views. + *args: + All positional arguments are forwarded to the `contents` parameter for convenience. + contents: + The contents of the container. Each item in the iterable must be a `SpaceView` or a `Container`. + This can only be used if no positional arguments are provided. active_tab: The index or name of the active tab. """ - super().__init__(*contents, kind=ContainerKind.Tabs, active_tab=active_tab) + super().__init__(*args, contents=contents, kind=ContainerKind.Tabs, active_tab=active_tab)