From b0574c23d8cb46203e734a989222ea356aa11d9c Mon Sep 17 00:00:00 2001 From: Don Date: Fri, 26 Apr 2024 16:27:50 -0400 Subject: [PATCH 1/3] docs: start plotting docs including one-click behaviour (#431) Resolves #413 - Reename examples as docs - Create a dh.ui plotting doc - Rename assets as _assets for folder sort order - Change links from assets to _assets - Prettier changes on md files touched --- plugins/ui/DESIGN.md | 100 ++++++++------- plugins/ui/README.md | 2 +- plugins/ui/docs/Plotting.md | 114 ++++++++++++++++++ plugins/ui/{examples => docs}/README.md | 50 ++++---- .../assets => docs/_assets}/button_events.png | Bin .../_assets}/change_monitor.png | Bin .../assets => docs/_assets}/checkbox.png | Bin .../assets => docs/_assets}/counter.png | Bin .../assets => docs/_assets}/docker.png | Bin .../assets => docs/_assets}/double-tft.png | Bin .../assets => docs/_assets}/double_table.png | Bin .../assets => docs/_assets}/foo_bar.png | Bin .../assets => docs/_assets}/form.png | Bin .../assets => docs/_assets}/form_submit.png | Bin .../_assets}/handling_events.png | Bin .../assets => docs/_assets}/hello_world.png | Bin .../_assets}/multiwave_dashboard.png | Bin .../assets => docs/_assets}/my_dash.png | Bin .../assets => docs/_assets}/picker.png | Bin .../assets => docs/_assets}/range_table.png | Bin .../assets => docs/_assets}/shared_state.png | Bin .../assets => docs/_assets}/stock_rollup.png | Bin .../_assets}/stock_widget_plot.png | Bin .../_assets}/stock_widget_table_invalid.png | Bin .../_assets}/stock_widget_table_valid.png | Bin .../assets => docs/_assets}/table_events.png | Bin .../assets => docs/_assets}/table_hooks.png | Bin .../assets => docs/_assets}/text_field.png | Bin .../_assets}/text_filter_table.png | Bin .../assets => docs/_assets}/wave_input.png | Bin .../assets => docs/_assets}/waves.png | Bin .../_assets}/waves_with_plot.png | Bin 32 files changed, 194 insertions(+), 72 deletions(-) create mode 100644 plugins/ui/docs/Plotting.md rename plugins/ui/{examples => docs}/README.md (96%) rename plugins/ui/{examples/assets => docs/_assets}/button_events.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/change_monitor.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/checkbox.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/counter.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/docker.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/double-tft.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/double_table.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/foo_bar.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/form.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/form_submit.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/handling_events.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/hello_world.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/multiwave_dashboard.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/my_dash.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/picker.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/range_table.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/shared_state.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/stock_rollup.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/stock_widget_plot.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/stock_widget_table_invalid.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/stock_widget_table_valid.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/table_events.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/table_hooks.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/text_field.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/text_filter_table.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/wave_input.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/waves.png (100%) rename plugins/ui/{examples/assets => docs/_assets}/waves_with_plot.png (100%) diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index cbdb538bb..a50bc1912 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1045,11 +1045,11 @@ ui.section( ###### Parameters -| Parameter | Type | Description | -| ----------- | ------- | ----------------------------------------- | -| `*children` | `Item` | The options to render within the section. | +| Parameter | Type | Description | +| ----------- | ------------- | ----------------------------------------- | +| `*children` | `Item` | The options to render within the section. | | `title` | `str \| None` | The title of the section. | -| `**props` | `Any` | Any other Section prop | +| `**props` | `Any` | Any other Section prop | ##### ui.picker @@ -1189,6 +1189,7 @@ picker7 = ui.picker( ``` ###### ui.list_action_group + A group of action buttons that can be used to create a list of actions. This component should be used within the actions prop of a `ListView` component. @@ -1202,16 +1203,16 @@ def list_action_group( ``` ###### Parameters -| Parameter | Type | Description | -|-------------------------|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `*children` | `ActionGroupItem` | The actions to render within the action group. | -| `on_action` | `Callable[[ActionKey, Key], None] \| None` | Handler that is called when an item is pressed. The first argument is the key of the action, the second argument is the key of the list_view item. | -| `on_selection_change` | `Callable[[Selection, Key], None] \| None` | Handler that is called when the selection changes. The first argument is the selection, the second argument is the key of the list_view item. | -| `**props` | `Any` | Any other [ActionGroup](https://react-spectrum.adobe.com/react-spectrum/ActionGroup.html) prop. | - +| Parameter | Type | Description | +| --------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `*children` | `ActionGroupItem` | The actions to render within the action group. | +| `on_action` | `Callable[[ActionKey, Key], None] \| None` | Handler that is called when an item is pressed. The first argument is the key of the action, the second argument is the key of the list_view item. | +| `on_selection_change` | `Callable[[Selection, Key], None] \| None` | Handler that is called when the selection changes. The first argument is the selection, the second argument is the key of the list_view item. | +| `**props` | `Any` | Any other [ActionGroup](https://react-spectrum.adobe.com/react-spectrum/ActionGroup.html) prop. | ###### ui.list_action_menu + A group of action buttons that can be used to create a list of actions. This component should be used within the actions prop of a `ListView` component. @@ -1225,17 +1226,20 @@ def list_action_menu( ``` ###### Parameters -| Parameter | Type | Description | -|-------------------------|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `*children` | `ActionMenuItem` | The options to render within the picker. | -| `on_action` | `Callable[[ActionKey, Key], None] \| None` | Handler that is called when an item is pressed. The first argument is the key of the action, the second argument is the key of the list_view item. | -| `on_open_change` | `Callable[[bool, Key], None] \| None` | The first argument is a boolean indicating if the menu is open, the second argument is the key of the list_view item. | -| `**props` | `Any` | Any other [ActionMenu](https://react-spectrum.adobe.com/react-spectrum/ActionMenu.html) prop. | + +| Parameter | Type | Description | +| ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `*children` | `ActionMenuItem` | The options to render within the picker. | +| `on_action` | `Callable[[ActionKey, Key], None] \| None` | Handler that is called when an item is pressed. The first argument is the key of the action, the second argument is the key of the list_view item. | +| `on_open_change` | `Callable[[bool, Key], None] \| None` | The first argument is a boolean indicating if the menu is open, the second argument is the key of the list_view item. | +| `**props` | `Any` | Any other [ActionMenu](https://react-spectrum.adobe.com/react-spectrum/ActionMenu.html) prop. | ###### ui.list_view -A list view that can be used to create a list of items. Children should be one of two types: -1. If children are of type `Item`, they are the list items. -2. If children are of type `Table`, the values in the table are the list items. There can only be one child, the `Table`. + +A list view that can be used to create a list of items. Children should be one of two types: + +1. If children are of type `Item`, they are the list items. +2. If children are of type `Table`, the values in the table are the list items. There can only be one child, the `Table`. ```py import deephaven.ui as ui @@ -1249,28 +1253,28 @@ ui.list_view( default_selected_keys: Selection | None = None, selected_keys: Selection | None = None, render_empty_state: Element | None = None, - on_selection_change: Callable[[Selection], None] | None = None, + on_selection_change: Callable[[Selection], None] | None = None, on_change: Callable[[Selection], None] | None = None, **props: Any ) -> ListViewElement ``` ###### Parameters -| Parameter | Type | Description | -|-------------------------|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*children` | `Item \| Table` | The options to render within the list_view. | -| `key_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to use as item keys. Defaults to the first column. | -| `label_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to display as primary text. Defaults to the `key_column` value. | -| `description_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to display as descriptions. | -| `icon_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to map to icons. | -| `actions` | `ListActionGroupElement \| ListActionMenuElement \| None` | Only valid if children are of type Table. The action group or menus to render for all elements within the list view. | -| `default_selected_keys` | `Selection \| None` | The initial selected keys in the collection (uncontrolled). | -| `selected_keys` | `Selection \| None` | The currently selected keys in the collection (controlled). | -| `render_empty_state` | `Element \| None` | Sets what the `list_view` should render when there is no content to display. | -| `on_selection_change` | `Callable[[Selection], None] \| None` | Handler that is called when the selections changes. | -| `on_change` | `Callable[[Selection], None] \| None` | Alias of `on_selection_change`. Handler that is called when the selections changes. | -| `**props` | `Any` | Any other [ListView](https://react-spectrum.adobe.com/react-spectrum/ListView.html) prop, with the exception of `items`, `dragAndDropHooks`, and `onLoadMore`. | +| Parameter | Type | Description | +| ----------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `*children` | `Item \| Table` | The options to render within the list_view. | +| `key_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to use as item keys. Defaults to the first column. | +| `label_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to display as primary text. Defaults to the `key_column` value. | +| `description_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to display as descriptions. | +| `icon_column` | `ColumnName \| None` | Only valid if children are of type `Table`. The column of values to map to icons. | +| `actions` | `ListActionGroupElement \| ListActionMenuElement \| None` | Only valid if children are of type Table. The action group or menus to render for all elements within the list view. | +| `default_selected_keys` | `Selection \| None` | The initial selected keys in the collection (uncontrolled). | +| `selected_keys` | `Selection \| None` | The currently selected keys in the collection (controlled). | +| `render_empty_state` | `Element \| None` | Sets what the `list_view` should render when there is no content to display. | +| `on_selection_change` | `Callable[[Selection], None] \| None` | Handler that is called when the selections changes. | +| `on_change` | `Callable[[Selection], None] \| None` | Alias of `on_selection_change`. Handler that is called when the selections changes. | +| `**props` | `Any` | Any other [ListView](https://react-spectrum.adobe.com/react-spectrum/ListView.html) prop, with the exception of `items`, `dragAndDropHooks`, and `onLoadMore`. | ```py import deephaven.ui as ui @@ -1355,19 +1359,22 @@ list_view7 = ui.list_view( ``` ###### ui.date_picker -A date picker that can be used to select a date. + +A date picker that can be used to select a date. There are three types that can be passed in to the props that control the date format: + 1. `LocalDate`: A LocalDate is a date without a time zone in the ISO-8601 system, such as "2007-12-03" or "2057-01-28". -This will create a date picker with a granularity of days. + This will create a date picker with a granularity of days. 2. `Instant`: An Instant represents an unambiguous specific point on the timeline, such as 2021-04-12T14:13:07 UTC. -This will create a date picker with a granularity of seconds in UTC. + This will create a date picker with a granularity of seconds in UTC. 3. `ZonedDateTime`: A ZonedDateTime represents an unambiguous specific point on the timeline with an associated time zone, such as 2021-04-12T14:13:07 America/New_York. -This will create a date picker with a granularity of seconds in the specified time zone. + This will create a date picker with a granularity of seconds in the specified time zone. -The format of the date picker and the type of the value passed to the `on_change` handler +The format of the date picker and the type of the value passed to the `on_change` handler is determined by the type of the following props in order of precedence: -1. `value` + +1. `value` 2. `default_value` 3. `placeholder_value` @@ -1389,8 +1396,9 @@ ui.date_picker( ``` ###### Parameters + | Parameter | Type | Description | -|----------------------|----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| -------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `placeholder_value` | `Date \| None` | A placeholder date that influences the format of the placeholder shown when no value is selected. Defaults to today at midnight in the user's timezone. | | `value` | `Date \| None` | The current value (controlled). | | `default_value` | `Date \| None` | The default value (uncontrolled). | @@ -1516,7 +1524,7 @@ ui.combo_box( ###### Parameters | Parameter | Type | Description | -| ---------------------- | ----------------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ---------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `*children` | `Item \| SectionElement \| Table \| PartitionedTable` | The options to render within the combo_box. | | `key_column` | `ColumnName \| None` | Only valid if children are of type `Table` or `PartitionedTable`. The column of values to use as item keys. Defaults to the first column. | | `label_column` | `ColumnName \| None` | Only valid if children are of type `Table` or `PartitionedTable`. The column of values to display as primary text. Defaults to the `key_column` value. | @@ -1530,7 +1538,7 @@ ui.combo_box( | `on_input_change` | `Callable[[str], None] \| None` | Handler that is called when the search input value changes. | | `on_selection_change` | `Callable[[Key], None] \| None` | Handler that is called when the selection changes. | | `on_change` | `Callable[[Key], None] \| None` | Alias of `on_selection_change`. Handler that is called when the selection changes. | -| `on_open_change` | `Callable[[bool, MenuTriggerAction], None] \| None` | Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. | +| `on_open_change` | `Callable[[bool, MenuTriggerAction], None] \| None` | Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. | | `**props` | `Any` | Any other [Combo_Box](https://react-spectrum.adobe.com/react-spectrum/ComboBox.html) prop, with the exception of `items`, `validate`, `errorMessage` (as a callback) and `onLoadMore` | ```py @@ -2186,7 +2194,7 @@ ListViewItem = Stringable | ItemElement LocalDateConvertible = Union[None, LocalDate, str, datetime.date, datetime.datetime, numpy.datetime64, pandas.Timestamp] InstantConvertible = Union[None, Instant, int, str, datetime.datetime, numpy.datetime64, pandas.Timestamp] ZonedDateTimeConvertible = Union[None, ZonedDateTime, str, datetime.datetime, numpy.datetime64, pandas.Timestamp] -Date = Instant | LocalDate | ZonedDateTime | LocalDateConvertible | InstantConvertible | ZonedDateTimeConvertible +Date = Instant | LocalDate | ZonedDateTime | LocalDateConvertible | InstantConvertible | ZonedDateTimeConvertible Granularity = Literal["DAY", "HOUR", "MINUTE", "SECOND"] MenuTriggerAction = Literal["FOCUS", "INPUT", "MANUAL"] @@ -2358,7 +2366,7 @@ tft = double_text_filter_table(_stocks) Which should result in a UI like this: -![Double Text Filter Tables](examples/assets/double-tft.png) +![Double Text Filter Tables](docs/_assets/double-tft.png) How does that look when the notebook is executed? When does each code block execute? diff --git a/plugins/ui/README.md b/plugins/ui/README.md index 7dd21cfdc..25bc1f55c 100644 --- a/plugins/ui/README.md +++ b/plugins/ui/README.md @@ -32,7 +32,7 @@ tox -e py ## Usage -Once you have the JS and python plugins installed and the server started, you can use deephaven.ui. See [examples](examples/README.md) for examples. +Once you have the JS and python plugins installed and the server started, you can use deephaven.ui. See [examples](docs/README.md) for examples. ## Logging diff --git a/plugins/ui/docs/Plotting.md b/plugins/ui/docs/Plotting.md new file mode 100644 index 000000000..729f68e76 --- /dev/null +++ b/plugins/ui/docs/Plotting.md @@ -0,0 +1,114 @@ +--- +title: Plotting and dh.ui +--- + +Creating dynamic plots that respond to user input is a common task in data analysis. The `dh.ui` module provides a simple interface for creating interactive plots using the `deephaven-express` library. This guide will show you how to create plots that updates based on user input. + +## Plotting a filtered table + +This example demonstrates how to create a simple line plot that updates based on user input. The plot will display the price of a stock filtered based on the stock symbol entered by the user. Here we have used a `ui.text_field` to get the value, but it could be driven by any dh.ui input, including double clicking on a value from a `ui.table`. We've previously referred to this sort of behaviour as a "one-click" component in enterprise, as the plot updates as soon as the user enters a filter. + +```python +import deephaven.plot.express as dx +import deephaven.ui as ui + +_stocks = dx.data.stocks() + + +@ui.component +def plot_filtered_table(table, initial_value): + text, set_text = ui.use_state("DOG") + # the filter is memoized so that it is only recalculated when the text changes + filtered_table = ui.use_memo( + lambda: table.where(f"sym = `{text.upper()}`"), [table, text] + ) + return [ + ui.text_field(value=text, on_change=set_text), + dx.line(filtered_table, x="timestamp", y="price", title=f"Filtered by: {text}"), + ] + + +p = plot_filtered_table(_stocks, "DOG") +``` + +## Plotting a partitioned table + +Using a partitioned table, as opposed to a where, can be more efficient if you are going to be filtering the same table multiple times with different values. This is because the partitioning is only done once, and then the key is selected from the partitioned table. Compared to using a where statement, it can be faster to return results, but at the expense of the query engine using more memory. Depending on the size of your table and the number of unique values in the partition key, this can be a tradeoff worth making or not. + +```python +import deephaven.plot.express as dx +import deephaven.ui as ui + +_stocks = dx.data.stocks() + + +@ui.component +def plot_partitioned_table(table, initial_value): + text, set_text = ui.use_state(initial_value) + # memoize the partition by so that it only performed once + partitioned_table = ui.use_memo(lambda: table.partition_by(["sym"]), [table]) + constituent_table = ui.use_memo( + lambda: partitioned_table.get_constituent(text.upper()), + [partitioned_table, text], + ) + return [ + ui.text_field(value=text, on_change=set_text), + # only attempt to plot valid partition keys + dx.line( + constituent_table, x="timestamp", y="price", title=f"partition key: {text}" + ) + if constituent_table != None + else ui.text("Please enter a valid partition."), + ] + + +p = plot_partitioned_table(_stocks, "DOG") +``` + +## Combining a fitler and a partition by + +Deephaven Express allows you to plot by a partition and assign unique colors to each key. Sometimes as a user you may also want to filter the data in addition to partitioning it. We've previously referred to this as a "one-click plot by" behaviour in enterprise. This can be done by either filtering the table first and then partitioning it, or partitioning it first and then filtering it. The choice of which to use depends on the size of the table and the number of unique values in the partition key. The first example is more like a traditional "one-click" component, and the second is more like a parameterized query. Both will give you the same result, but the first one may return results faster, whereas the second one may be more memory efficient. + +```python +import deephaven.plot.express as dx +import deephaven.ui as ui + +_stocks = dx.data.stocks() + + +@ui.component +def partition_then_filter(table, by, initial_value): + """ + Partition the table by both passed columns, then filter it by the value entered by the user + """ + text, set_text = ui.use_state(initial_value) + partitioned_table = ui.use_memo(lambda: table.partition_by(by), [table, by]) + filtered = ui.use_memo( + lambda: partitioned_table.filter(f"{by[0]} = `{text.upper()}`"), + [text, partitioned_table], + ) + return [ + ui.text_field(value=text, on_change=set_text), + dx.line(filtered, x="timestamp", y="price", by=[f"{by[1]}"]), + ] + + +@ui.component +def where_then_partition(table, by, initial_value): + """ + Filter the table by the value entered by the user, then re-partition it by the second passed column + """ + text, set_text = ui.use_state(initial_value) + filtered = ui.use_memo( + lambda: table.where(f"{by[0]} = `{text.upper()}`"), [text, table] + ) + return [ + ui.text_field(value=text, on_change=set_text), + dx.line(filtered, x="timestamp", y="price", by=[f"{by[1]}"]), + ] + + +# outputs the same thing, done two different ways depending on how you want the work done +ptf = partition_then_filter(_stocks, ["sym", "exchange"], "DOG") +wtp = where_then_partition(_stocks, ["sym", "exchange"], "DOG") +``` diff --git a/plugins/ui/examples/README.md b/plugins/ui/docs/README.md similarity index 96% rename from plugins/ui/examples/README.md rename to plugins/ui/docs/README.md index e1e4002b7..a385611b8 100644 --- a/plugins/ui/examples/README.md +++ b/plugins/ui/docs/README.md @@ -11,7 +11,7 @@ docker run --rm --name deephaven-ui -p 10000:10000 --pull=always ghcr.io/deephav ``` You'll need to find the link to open the UI in the Docker logs: -![docker](assets/docker.png) +![docker](_assets/docker.png) # Using components @@ -27,7 +27,7 @@ The `ui` package contains many _components_, which you can display in the UI: hello_world = ui.heading("Hello World!") ``` -![Basic Hello World example.](assets/hello_world.png) +![Basic Hello World example.](_assets/hello_world.png) By assigning the component to the `hello_world` variable, it displays in the UI in a panel named `hello_world`. @@ -39,7 +39,7 @@ Write functions to handle events. To write a button that will print event detail my_button = ui.button("Click Me!", on_press=lambda e: print(f"Button was clicked! {e}")) ``` -![Whenever the button is pressed, event details are printed to the console.](assets/handling_events.png) +![Whenever the button is pressed, event details are printed to the console.](_assets/handling_events.png) ## Creating components @@ -59,7 +59,7 @@ def ui_foo_bar(): foo_bar = ui_foo_bar() ``` -![Custom component being displayed.](assets/foo_bar.png) +![Custom component being displayed.](_assets/foo_bar.png) ## Using state @@ -98,7 +98,7 @@ c1 = ui_counter() c2 = ui_counter() ``` -![Each counter has its own state.](assets/counter.png) +![Each counter has its own state.](_assets/counter.png) > [!NOTE] > Functions are prefixed with `use_` are called _hooks_. `use_state` is built-in to deephaven.ui, and there are other hooks built-in shown below. You can also create your own hooks. @@ -160,7 +160,7 @@ def ui_shared_state(): shared_state = ui_shared_state() ``` -![Buttons will always be in sync with shared state.](assets/shared_state.png) +![Buttons will always be in sync with shared state.](_assets/shared_state.png) # Examples @@ -181,7 +181,7 @@ def ui_input(): my_input = ui_input() ``` -![Text field.](assets/text_field.png) +![Text field.](_assets/text_field.png) ## Checkbox (boolean) @@ -201,7 +201,7 @@ def ui_checkbox(): my_checkbox = ui_checkbox() ``` -![Checkbox](assets/checkbox.png) +![Checkbox](_assets/checkbox.png) ## Picker (string values) @@ -238,7 +238,7 @@ def ui_picker(): my_picker = ui_picker() ``` -![Use a picker to select from a list of items](assets/picker.png) +![Use a picker to select from a list of items](_assets/picker.png) ## Form (two variables) @@ -261,7 +261,7 @@ def ui_form(): my_form = ui_form() ``` -![Form with multiple inputs.](assets/form.png) +![Form with multiple inputs.](_assets/form.png) ## Form with submit @@ -284,7 +284,7 @@ def ui_form_submit(): my_form_submit = ui_form_submit() ``` -![Submitting a form and printing out the data.](assets/form_submit.png) +![Submitting a form and printing out the data.](_assets/form_submit.png) ## Button events @@ -320,7 +320,7 @@ def ui_button_events(): my_button_events = ui_button_events() ``` -![Print the details of all events when pressing a button.](assets/button_events.png) +![Print the details of all events when pressing a button.](_assets/button_events.png) # Data Examples @@ -352,7 +352,7 @@ def ui_text_filter_table(source, column): my_text_filter_table = ui_text_filter_table(stocks, "sym") ``` -![Table with a text field for filtering.](assets/text_filter_table.png) +![Table with a text field for filtering.](_assets/text_filter_table.png) ## Table with range filter @@ -415,9 +415,9 @@ def ui_stock_widget_table(source, default_sym="", default_exchange=""): my_stock_widget_table = ui_stock_widget_table(stocks, "", "") ``` -![Stock Widget Table Invalid Input](assets/stock_widget_table_invalid.png) +![Stock Widget Table Invalid Input](_assets/stock_widget_table_invalid.png) -![Stock Widget Table Valid Input](assets/stock_widget_table_valid.png) +![Stock Widget Table Valid Input](_assets/stock_widget_table_valid.png) ## Plot with filters @@ -451,7 +451,7 @@ def ui_stock_widget_plot(source, default_sym="", default_exchange=""): my_stock_widget_plot = ui_stock_widget_plot(stocks, "CAT", "TPET") ``` -![Stock Widget Plot](assets/stock_widget_plot.png) +![Stock Widget Plot](_assets/stock_widget_plot.png) # Dashboard Examples @@ -513,7 +513,7 @@ my_dash = ui.dashboard( ) ``` -![Stock Dashboard](assets/my_dash.png) +![Stock Dashboard](_assets/my_dash.png) ## Custom Components Dashboard @@ -611,7 +611,7 @@ def multiwave(): mw = ui.dashboard(multiwave()) ``` -![Multiwave Dashboard](assets/multiwave_dashboard.png) +![Multiwave Dashboard](_assets/multiwave_dashboard.png) # Other Examples @@ -666,7 +666,7 @@ def waves(): w = waves() ``` -![Waves](assets/waves.png) +![Waves](_assets/waves.png) ## Custom hook @@ -730,7 +730,7 @@ def waves(): w = waves() ``` -![Wave Input](assets/wave_input.png) +![Wave Input](_assets/wave_input.png) We can then re-use that hook to make a component that displays a plot as well: @@ -762,7 +762,7 @@ def waves_with_plot(): wp = waves_with_plot() ``` -![Waves with plot](assets/waves_with_plot.png) +![Waves with plot](_assets/waves_with_plot.png) ## Using Panels @@ -910,7 +910,7 @@ def double_table(source): dt = double_table(stocks) ``` -![Double Table](assets/double_table.png) +![Double Table](_assets/double_table.png) ## Stock rollup @@ -994,7 +994,7 @@ def stock_table(source): st = stock_table(stocks) ``` -![Stock Rollup](assets/stock_rollup.png) +![Stock Rollup](_assets/stock_rollup.png) ## Listening to Table Updates @@ -1092,7 +1092,7 @@ Without the `use_liveness_scope` wrapping the lamdba, the newly created live tab For more information on liveness scopes and why they are needed, see the [liveness scope documentation](https://deephaven.io/core/docs/conceptual/liveness-scope-concept/). -![Change Monitor](assets/change_monitor.png) +![Change Monitor](_assets/change_monitor.png) ## Tabs @@ -1194,7 +1194,7 @@ def watch_lizards(source: Table): watch = watch_lizards(stocks) ``` -![Table Hooks](assets/table_hooks.png) +![Table Hooks](_assets/table_hooks.png) ## Multi-threading diff --git a/plugins/ui/examples/assets/button_events.png b/plugins/ui/docs/_assets/button_events.png similarity index 100% rename from plugins/ui/examples/assets/button_events.png rename to plugins/ui/docs/_assets/button_events.png diff --git a/plugins/ui/examples/assets/change_monitor.png b/plugins/ui/docs/_assets/change_monitor.png similarity index 100% rename from plugins/ui/examples/assets/change_monitor.png rename to plugins/ui/docs/_assets/change_monitor.png diff --git a/plugins/ui/examples/assets/checkbox.png b/plugins/ui/docs/_assets/checkbox.png similarity index 100% rename from plugins/ui/examples/assets/checkbox.png rename to plugins/ui/docs/_assets/checkbox.png diff --git a/plugins/ui/examples/assets/counter.png b/plugins/ui/docs/_assets/counter.png similarity index 100% rename from plugins/ui/examples/assets/counter.png rename to plugins/ui/docs/_assets/counter.png diff --git a/plugins/ui/examples/assets/docker.png b/plugins/ui/docs/_assets/docker.png similarity index 100% rename from plugins/ui/examples/assets/docker.png rename to plugins/ui/docs/_assets/docker.png diff --git a/plugins/ui/examples/assets/double-tft.png b/plugins/ui/docs/_assets/double-tft.png similarity index 100% rename from plugins/ui/examples/assets/double-tft.png rename to plugins/ui/docs/_assets/double-tft.png diff --git a/plugins/ui/examples/assets/double_table.png b/plugins/ui/docs/_assets/double_table.png similarity index 100% rename from plugins/ui/examples/assets/double_table.png rename to plugins/ui/docs/_assets/double_table.png diff --git a/plugins/ui/examples/assets/foo_bar.png b/plugins/ui/docs/_assets/foo_bar.png similarity index 100% rename from plugins/ui/examples/assets/foo_bar.png rename to plugins/ui/docs/_assets/foo_bar.png diff --git a/plugins/ui/examples/assets/form.png b/plugins/ui/docs/_assets/form.png similarity index 100% rename from plugins/ui/examples/assets/form.png rename to plugins/ui/docs/_assets/form.png diff --git a/plugins/ui/examples/assets/form_submit.png b/plugins/ui/docs/_assets/form_submit.png similarity index 100% rename from plugins/ui/examples/assets/form_submit.png rename to plugins/ui/docs/_assets/form_submit.png diff --git a/plugins/ui/examples/assets/handling_events.png b/plugins/ui/docs/_assets/handling_events.png similarity index 100% rename from plugins/ui/examples/assets/handling_events.png rename to plugins/ui/docs/_assets/handling_events.png diff --git a/plugins/ui/examples/assets/hello_world.png b/plugins/ui/docs/_assets/hello_world.png similarity index 100% rename from plugins/ui/examples/assets/hello_world.png rename to plugins/ui/docs/_assets/hello_world.png diff --git a/plugins/ui/examples/assets/multiwave_dashboard.png b/plugins/ui/docs/_assets/multiwave_dashboard.png similarity index 100% rename from plugins/ui/examples/assets/multiwave_dashboard.png rename to plugins/ui/docs/_assets/multiwave_dashboard.png diff --git a/plugins/ui/examples/assets/my_dash.png b/plugins/ui/docs/_assets/my_dash.png similarity index 100% rename from plugins/ui/examples/assets/my_dash.png rename to plugins/ui/docs/_assets/my_dash.png diff --git a/plugins/ui/examples/assets/picker.png b/plugins/ui/docs/_assets/picker.png similarity index 100% rename from plugins/ui/examples/assets/picker.png rename to plugins/ui/docs/_assets/picker.png diff --git a/plugins/ui/examples/assets/range_table.png b/plugins/ui/docs/_assets/range_table.png similarity index 100% rename from plugins/ui/examples/assets/range_table.png rename to plugins/ui/docs/_assets/range_table.png diff --git a/plugins/ui/examples/assets/shared_state.png b/plugins/ui/docs/_assets/shared_state.png similarity index 100% rename from plugins/ui/examples/assets/shared_state.png rename to plugins/ui/docs/_assets/shared_state.png diff --git a/plugins/ui/examples/assets/stock_rollup.png b/plugins/ui/docs/_assets/stock_rollup.png similarity index 100% rename from plugins/ui/examples/assets/stock_rollup.png rename to plugins/ui/docs/_assets/stock_rollup.png diff --git a/plugins/ui/examples/assets/stock_widget_plot.png b/plugins/ui/docs/_assets/stock_widget_plot.png similarity index 100% rename from plugins/ui/examples/assets/stock_widget_plot.png rename to plugins/ui/docs/_assets/stock_widget_plot.png diff --git a/plugins/ui/examples/assets/stock_widget_table_invalid.png b/plugins/ui/docs/_assets/stock_widget_table_invalid.png similarity index 100% rename from plugins/ui/examples/assets/stock_widget_table_invalid.png rename to plugins/ui/docs/_assets/stock_widget_table_invalid.png diff --git a/plugins/ui/examples/assets/stock_widget_table_valid.png b/plugins/ui/docs/_assets/stock_widget_table_valid.png similarity index 100% rename from plugins/ui/examples/assets/stock_widget_table_valid.png rename to plugins/ui/docs/_assets/stock_widget_table_valid.png diff --git a/plugins/ui/examples/assets/table_events.png b/plugins/ui/docs/_assets/table_events.png similarity index 100% rename from plugins/ui/examples/assets/table_events.png rename to plugins/ui/docs/_assets/table_events.png diff --git a/plugins/ui/examples/assets/table_hooks.png b/plugins/ui/docs/_assets/table_hooks.png similarity index 100% rename from plugins/ui/examples/assets/table_hooks.png rename to plugins/ui/docs/_assets/table_hooks.png diff --git a/plugins/ui/examples/assets/text_field.png b/plugins/ui/docs/_assets/text_field.png similarity index 100% rename from plugins/ui/examples/assets/text_field.png rename to plugins/ui/docs/_assets/text_field.png diff --git a/plugins/ui/examples/assets/text_filter_table.png b/plugins/ui/docs/_assets/text_filter_table.png similarity index 100% rename from plugins/ui/examples/assets/text_filter_table.png rename to plugins/ui/docs/_assets/text_filter_table.png diff --git a/plugins/ui/examples/assets/wave_input.png b/plugins/ui/docs/_assets/wave_input.png similarity index 100% rename from plugins/ui/examples/assets/wave_input.png rename to plugins/ui/docs/_assets/wave_input.png diff --git a/plugins/ui/examples/assets/waves.png b/plugins/ui/docs/_assets/waves.png similarity index 100% rename from plugins/ui/examples/assets/waves.png rename to plugins/ui/docs/_assets/waves.png diff --git a/plugins/ui/examples/assets/waves_with_plot.png b/plugins/ui/docs/_assets/waves_with_plot.png similarity index 100% rename from plugins/ui/examples/assets/waves_with_plot.png rename to plugins/ui/docs/_assets/waves_with_plot.png From f342dade60c5b62f20bc1b9b1579dc99daeac5e2 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 30 Apr 2024 10:08:13 -0500 Subject: [PATCH 2/3] fix: Memoize use_table_data listener (#428) Because the listener function was not memoized in `use_table_data`, the `use_effect` within `use_table_listener` was getting called unnecessarily, which means in some cases table updates can be missed during the start and stop of the listener. Added fix and caution message in `use_table_listener` for now. It might be worthwhile making `use_table_listener` more robust in cases where arguments changed, but the table specifically did not. This is not trivial though. --- .../src/deephaven/ui/hooks/use_table_data.py | 14 ++- .../deephaven/ui/hooks/use_table_listener.py | 2 + plugins/ui/test/deephaven/ui/test_hooks.py | 87 ++++++++++++++++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_table_data.py b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py index 200fe7049..0c9a5cedd 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_table_data.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py @@ -140,14 +140,20 @@ def use_table_data( else: executor_name = "concurrent" - table_updated = lambda: _set_new_data(table, sentinel, set_data, set_is_sentinel) + # memoize table_updated (and listener) so that they don't cause a start and stop of the listener + table_updated = ui.use_callback( + lambda: _set_new_data(table, sentinel, set_data, set_is_sentinel), + [table, sentinel], + ) # call table_updated in the case of new table or sentinel ui.use_effect(table_updated, [table, sentinel]) + listener = ui.use_callback( + partial(_on_update, ctx, table_updated, executor_name), + [table_updated, executor_name, ctx], + ) # call table_updated every time the table updates - ui.use_table_listener( - table, partial(_on_update, ctx, table_updated, executor_name), [] - ) + ui.use_table_listener(table, listener, []) return transform(data, is_sentinel) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py b/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py index 06ab38fc8..1052ef14e 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py @@ -74,6 +74,8 @@ def use_table_listener( ) -> None: """ Listen to a table and call a listener when the table updates. + If any dependencies change, the listener will be recreated. + In this case, updates may be missed if the table updates while the listener is being recreated. Args: table: The table to listen to. diff --git a/plugins/ui/test/deephaven/ui/test_hooks.py b/plugins/ui/test/deephaven/ui/test_hooks.py index 40cdf1900..090034a91 100644 --- a/plugins/ui/test/deephaven/ui/test_hooks.py +++ b/plugins/ui/test/deephaven/ui/test_hooks.py @@ -2,14 +2,16 @@ import unittest from operator import itemgetter from queue import Queue +from time import sleep from typing import Callable from unittest.mock import Mock from .BaseTest import BaseTestCase LISTENER_TIMEOUT = 2.0 +QUEUE_TIMEOUT = 1.0 -def render_hook(fn: Callable): +def render_hook(fn: Callable, queue=None): """ Render a hook function and return the context, result, and a rerender function for updating it @@ -17,10 +19,13 @@ def render_hook(fn: Callable): fn: Callable: The function to render. Pass in a function with a hook call within it. Re-render will call the same function but with the new args passed in. + queue: Queue: + The queue to put items on. If not provided, a new queue will be created. """ from deephaven.ui._internal.RenderContext import RenderContext - queue = Queue() + if queue is None: + queue = Queue() context = RenderContext(lambda x: queue.put(x), lambda x: queue.put(x)) @@ -42,6 +47,47 @@ def _rerender(*args, **kwargs): return return_dict +class NotifyQueue(Queue): + """ + A queue that notifies a function when an item is put on it + """ + + def __init__(self): + super().__init__() + self._notify_fn = None + + def put(self, item: object, block: bool = True, timeout: float = None) -> None: + """ + Put an item on the queue and notify the function + + Args: + item: The item to put on the queue + block: True if the call should block until the item is put on the queue + timeout: The time to wait for the item to be put on the queue + + Returns: + None + """ + super().put(item) + if self._notify_fn: + self._notify_fn(self) + + def call_after_put(self, fn: Callable[["NotifyQueue"], None]) -> None: + """ + Register a function to be called after an item is put on the queue + + Args: + fn: The function to call after an item is put on the queue + """ + self._notify_fn = fn + + def unregister_notify(self) -> None: + """ + Unregister the notify function + """ + self._notify_fn = None + + class HooksTest(BaseTestCase): def test_state(self): from deephaven.ui.hooks import use_state @@ -274,6 +320,34 @@ def _test_table_data(t=table): self.assertEqual(result, expected) + def verify_queue_has_size(self, queue: NotifyQueue, size: int): + """ + Verify that the queue has the expected size in a multi-threaded context + + Args: + queue: The queue to check + size: The expected size of the quexue + + Returns: + None + """ + event = threading.Event() + + def check_size(q): + if q.qsize() == size: + event.set() + + # call after each put in case the queue is not at the correct size yet + queue.call_after_put(check_size) + + # call now in case the queue is (or was) already at the correct size + check_size(queue) + + if not event.wait(timeout=QUEUE_TIMEOUT): + assert False, f"queue did not reach size {size}" + + queue.unregister_notify() + def test_swapping_table_data(self): from deephaven.ui.hooks import use_table_data from deephaven import new_table @@ -292,7 +366,9 @@ def _test_table_data(t=table): result = use_table_data(t, sentinel="sentinel") return result - render_result = render_hook(_test_table_data) + queue = NotifyQueue() + + render_result = render_hook(_test_table_data, queue) result, rerender = itemgetter("result", "rerender")(render_result) @@ -313,10 +389,13 @@ def _test_table_data(t=table): self.verify_table_updated(table_writer, dynamic_table, (1, "Testing")) rerender(dynamic_table) + # the queue should have two items eventually, one for each set_state in _set_new_data in use_table_data + # this check is needed because the set_state calls come from the listener, which is called in a separate thread + # so the queue might not have the correct size immediately + self.verify_queue_has_size(queue, 2) result = rerender(dynamic_table) expected = {"Numbers": [1], "Words": ["Testing"]} - self.assertEqual(result, expected) def test_column_data(self): From 19d6cd66637c5f6beb85451423f526e2782d2dc0 Mon Sep 17 00:00:00 2001 From: Matthew Runyon Date: Wed, 8 May 2024 14:43:21 -0500 Subject: [PATCH 3/3] test: Fix stale e2e server image locally (#454) Fixed an issue that caused the server edge image to be stale when running locally Added a few lines from web-client-ui to install fonts in the Dockerfile just in case they're needed Generated new snapshots from fira font change Removed a package.json script that didn't work Removed an env variable we weren't actually using for e2e tests --------- Co-authored-by: Don --- package.json | 3 +-- tests/Dockerfile | 10 ++++++++++ tests/docker-compose.yml | 4 +++- .../Express-loads-1-chromium-linux.png | Bin 7521 -> 7512 bytes .../Express-loads-1-webkit-linux.png | Bin 6704 -> 6719 bytes .../UI-loads-1-firefox-linux.png | Bin 14521 -> 14927 bytes 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 64acaf276..b46d2a626 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,7 @@ "test:ci": "run-p test:ci:*", "test:ci:unit": "jest --config jest.config.unit.cjs --ci --cacheDirectory $PWD/.jest-cache", "test:ci:lint": "jest --config jest.config.lint.cjs --ci --cacheDirectory $PWD/.jest-cache", - "e2e": "playwright run", - "e2e:docker": "DEEPHAVEN_PORT=10001 ./tools/run_docker.sh e2e-tests", + "e2e:docker": "./tools/run_docker.sh e2e-tests", "e2e:update-snapshots": "./tools/run_docker.sh update-snapshots", "update-dh-packages": "lerna run --concurrency 1 update-dh-packages" }, diff --git a/tests/Dockerfile b/tests/Dockerfile index ce77733aa..48eed433f 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -4,6 +4,16 @@ FROM mcr.microsoft.com/playwright:v1.41.2-jammy AS playwright WORKDIR /work/ +# Update packages list and install some build tools. +# Installing fonts-dejavu-core so we have some common fonts with the GH actions +# runner that can be used to render unicode fonts. See web-client-ui README for more info. +RUN set -eux; \ + apt-get update; \ + apt-get install build-essential --yes; \ + apt-get install fonts-dejavu-core --yes; + +RUN fc-list : family; + RUN npm install @playwright/test@1.41.2 COPY playwright.config.ts . COPY playwright-docker.config.ts . diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 7d9f728f1..d8a36f7f7 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -6,6 +6,7 @@ services: build: context: ../ dockerfile: ./Dockerfile + pull: true # We need to always pull the latest server image used in the Dockerfile expose: - 10000 volumes: @@ -18,6 +19,7 @@ services: build: context: ../ dockerfile: ./tests/Dockerfile + pull: true # We only use FROM with a pinned version here, but adding this just in case that changes in the future ports: - '9323:9323' ipc: host @@ -25,7 +27,7 @@ services: - ../tests:/work/tests - ../test-results:/work/test-results - ../playwright-report:/work/playwright-report - entrypoint: "npx playwright test --config=playwright-docker.config.ts" + entrypoint: 'npx playwright test --config=playwright-docker.config.ts' depends_on: deephaven-plugins: condition: service_healthy diff --git a/tests/express.spec.ts-snapshots/Express-loads-1-chromium-linux.png b/tests/express.spec.ts-snapshots/Express-loads-1-chromium-linux.png index 11a9903c23b42a92e223505e384a479cb1c44a06..5677b3c4c115bc77475f33310679e76030b4531f 100644 GIT binary patch literal 7512 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�>2d?9#(q(_7lbNRT&FXldZNH$ ztC#>elDW_L*kV3KMXBV-o=%P6Gth-f6%zT@2L4D!PcR&Z2wMr@>9q@{2_|XyMYbg1 z6mZ0P31F}@27wAVw8X$`c^37(Jk|qe!Af@flf`dgB(%z1=vj5PCc3IemzZcudK&6( zX`TnZ)4eogx|aCVUc@D1ZboLRY5Z7_Zb|i4fbLPVBX6SK?AU96GIRbO7oS$jjKKYY z+^o0dtoc(u8{cPO39OhNdT?!dopft*vvBVl43G$6`{sK4k%XQoB~K32R(e3( z%#GQTU+$akmSu95!GedF{5?AguOK|SKr+`}P285}@osgbUD)$` z&}E3ZF7w8p?xl5TY8{;LRGZgJ-{-)GE$tDc!k(c0EtYhPMm!_1Q9;wkzqZvW&n!Hl zlG*-DnjEt1k=WLE+tQ=Jz5;_E7c`2`+{x%s`%Nlzp|6PbRy@rWDmK{y@eUB(_EW=~h7CDynL_8e4RmHK&lev~4xU`J^eQzc{r%fw z-(D5sOc||bnHCN4BowIc98ju~+yPqC8pr92-jy7B<)XXJ1C~uepvJA zf^?o1_wzR#z2htNkBpRC{PKKayOb%#)m+wgu)Y`79{s8_PG*nR_T2fKAiQzw@BP9C z!~Z(#V9wzX$_sywTZWF`xHvCgWQKGZ+a~I5pfx|7U7fJ}xe{J$oX@1umy#RaWv=u_ z!{)kpLX_h7>Jh4^yimG=V>oLy%|M(1u6uzr&P|>q9hX{kTkZ6%S7>^rFvMNZ^p#mf zjS_KttZh(W&J}gtzo6LC`2)sGCIyM5B;ye~FI^`1&E4LCk~3BWc^7m=Wy@Bj_nd_* zH#%1eT*r(e&U-%=R-ouwC4qb;Er4}N3K+!l?N1WGPv9Uh6;F2?)rENV$pxo0i}}d1 zS77RiS&k3Im)_KKr>zEy?J+XaB7(_c%ZYp3)8 z%M;nv+rPqM?PIVk#uUNgzPigVd?gSXYXh^=nl9Y`hP4;{HBSl1oX-VadHMXnKg9mq z#q-}lbafRAT0_@w5B}d<=^f;7s40L{ihGp^n##R=(v^Ju+p4||P@D0eTDLhUi_%?4 zPV%_-MLK8?aRe!^6Q$yw^7Z~#^bRCV@avMl+5+t3e6Ai{xX$U`oo&>-2Weo*+VaL! z&Kl>w3yqSJOgkAO3>MjY3*Simta>#QN++j;9`X2e#N55)j}rDIud+u+{wPn_Vj)PA zchtqJA@_eK4DIx) zSBvqW%UhmfI@Wi%0tR)xl54BgFgJJDmhSgQ&aK;CwSRymx6AQf7w~zg*!t$FGUuH* z96&|F5o8?n%lR$VZlvMHG^g4)WfCJn)lIm-tu-2@%L+&yUECfXCt+gxZJB{WaY7%v6gxJ4IPu%rXYm^9)Dh=aO?1g1rInAD6v^Mo)Ip4h%tMG=X0G)bW ztO<->r$$=#?clU2^z2q=RZtxfk~q|zZQ_;m25sq~#1K2!Tytx)$c6AS_U4TMbLYA^ z3pY5PQps6%Gz(%#42>l@p>(8@OeRI6#MPqbc_ppm3c5=?65F%f61xVP7GSd?jjzvE zrKiVjx7A3x2#SKf^NR|&u08cyX~B`wx{AiS%>1|`t`{YIXMK?G&UKyG11?p6!?SJc zHcE;$r>Zc}^1*mwBH&_dYB6*Ihg5uZBO2#{lV?c<3=B-ychV|e#M+pzl+Jg!RoQzL z?`9P>?o+-}{iq!SWZDx$wWgc0ixyJPQtLG*4-ts0r7==LtGy(CGS_frxg2h>r6P9I ziVUcaLcRyCRqqM&go|wf;INJFk9K+{WqleG^RO;dsJ)}jI(5V{_4qlK=k;m-35^1$ z_A9Ybk~s3QlhG2#Isxy3W(y=9(ItqGfLWHXmc&U6afR*!$-7vW6CWi>tn}|NQP}M3 z!@p_cMqr2NP2u&1zP3gFJ}}jI=>+v}Ua5!#&$oeh#6{_IadA2!OYU4{!V160?jp7YmvkK1Ebtx#$xz-OgCSdwvn&H6wR1PEPA4X~62V^bz7U5DKBWip$ufcb?~wC9d;3p_ zLpXOByeBAQxO=E%Perhz=HLFMbJjwL+gqh>nE9LtEK?hf_}j-tcxnI|&*~`4dZJ$%N&4O&Upv3mX}02YY>3dtS+~ii3AjIwaU$;KZFS@dS?+ji4l7 z^Jy5-(`EkdhHYZ{u`!yYmd=%9*P7o5m7@cX)@u)~jO+JK|4;jHJ7At6YrpUQU9iG< zaQy16bpf9K1f&kuFha?JRY`qdx{l>HbNX%tEIeC|>@!Xjl{E}~d!P#&EOUNu z_@F|3)pihsTx1Q+M(8^F>lzB2GINOLaLCdqNWD_?1XPb2m9I6qfa&nTXz4IguYhM% zEnV6u;YgH_I9346hsVz)9L?%b6zo^B!fCb{7&28-{HmURKLW^dILjU$fBX|I{{BO7x4dv2 z{%c)_VDtagt)A+MgO$|FbIIF3zcfnN>o?I+kOocaP%dyB)rxYgQ*DiG&}C~?MoU6q z@Im6x>vW}`kk^G>;D$bT7$MQ3!T-HhbLCv-k>iSeAX!%JIYA*ho!8mlRX6SYAqM z>D22YUWt5o{?$hLdd*iRul+Kt<@o`%_I?weg=dC!GjjY}OVt#K?kPa_o0Olh-E2Lc z!6MEn3%8X+m|{=w!~j@Tz(jLvgfjz_#;+Dq=Y7fuyx=L-=d-2Z_51xJg)D58jYqCq zuMTt$urxb}{&D|UigFK%0)uKk^?@!1<#)EFIUy7;5JQPOT@Ln-7yti6tg z--rOQv)3OC4MEh_ko>ki%jGJwij)4AU{fq!(wpKvT3X)3w(B(tl#lZWcE!+qrYGo` zLu_q(o;E1`sIm{GVz|B3Vt{DxeSs#bI+|CMO(Q+PzQ;S3rL+;cp*bC+#Jk|RjLGb1 zpmF5N<;&BHZ|6o&B4Uj1D&)Eu$EzQ_!xy5CDP4M7-J5$lW?$o6q$zmn;Z_OpBA98R zX|cqW0e1_a=Q`aZ=kE=~C_hM+xEFB_dPA3c=!h9>yYCn|hRH*LFo}Xqi$W^@=*t^p^_UW_FE>~Tz!3wxQ z$He@*LM^%i%8f|2^+rHZ)5Wuun1F2qVgCzfnYtCfeGDy|4p?^lP!wT%}h1kXSrJ zU3QFCUo=*~o3wljBPI$SA|iW+7*UVSuSs+VC+`9eBUM3aU_HO++i*-5Jh6!>nxg1T zSCEqvm4H;${q zOJg9vK4lo2-VQ|Q5^t7_zkEPs`-}NW20P>LyvB-UnEapG{@T%HT-m3?*Q3;2`xU8 zi=`0?_`oUiZlSP6znK$;)?O_d(K?j2BGyYcDprl98qDg=PDMQBDw}Kd5{lh6EV4Xa z0B^#WX?hYSD&-S3OD(k*%H5XoN0g!CZC7KmxNFf~MOQkrR?kXFoz0s7Nd8sj&ar_x z7ZJdmowPK9h183#iE76Tb)h2mxHuC!LO~}ad~%NCMd{}X-xyquXYtpZ+!(|bI%})rU%E6G*Dem zX?xwR*iL8d+d1dV2l#h;45?4vCRX6!o)#tS;p}HVS8!JrTOgjx_m;%mG6h?0bOY`v z)PfTqYtB#uC%)}Z*DX`-TK|1~n1k`*Ae9m&sd99QAudpZy)Vi6sno5@ud3UJ>8B>l zAS*+5x1}27sgG3DKk$;B48DM-e2hkK0r<6s!`d#rf;P@Q!sO^fXQmR^u#EH=uK`Kz zpc_pX+x;4eA9Fq(ZnPDQr$CWXAc7$Mq4mxQZKhdxutVTZX z98RIoA~Rb1{&iv9nmMFj+Q33xJJ&aLN6A4=!1kfR#`p z&Yns1oe#9St43I`E8i;)CV^;Fi$_OAzN;y1)GoR1?$l>lk z)SKbx?HJ3HjI|9tt>sO=)ktKA#aS!+Z6HECi3c~#5`WiB?0@>mX6}z4+@L43DZHPn z?5*F}$t>%F)YQ~Hn+_X&CFS2$cb0M75BUL+edkL5(I&sWb;<~=vO4sCGB>>xeUP@_ zi#ziZhV-k|Kh+a>@yi;&tnn*S{=uhTxbY2MWqzUPcbVvyHNFSRU)K0#jbH5RJ9zlT zqrMM_U)K0#jbE(t7pweLn}1QIf8pUTJp6@+zwq$?7#@bRc@N1Rg!@E{Ai;kW1x_0n K>tp_KxcOgzeTTdN literal 14521 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