From 05bbd5954bd728353d3c924232ebc4eb69675955 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 21 Dec 2023 09:25:30 -0600 Subject: [PATCH 1/8] fix: Fixed time preprocessor test (#181) Fixed time preprocessor tests that were failing due to more granular engine types --- .../plot/express/preprocess/test_TimePreprocessor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/plotly-express/test/deephaven/plot/express/preprocess/test_TimePreprocessor.py b/plugins/plotly-express/test/deephaven/plot/express/preprocess/test_TimePreprocessor.py index c19629815..29e131bfa 100644 --- a/plugins/plotly-express/test/deephaven/plot/express/preprocess/test_TimePreprocessor.py +++ b/plugins/plotly-express/test/deephaven/plot/express/preprocess/test_TimePreprocessor.py @@ -40,14 +40,15 @@ def test_time_preprocessor(self): expected_df = pd.DataFrame( { - "Start": ["2021-07-04 12:00:00"], - "End": ["2021-07-04 13:00:00"], + "Start": ["2021-07-04 12:00:00+00:00"], + "End": ["2021-07-04 13:00:00+00:00"], "Category": ["A"], "x_diff": [3600000.0], } ) expected_df["Start"] = pd.to_datetime(expected_df["Start"]) expected_df["End"] = pd.to_datetime(expected_df["End"]) + expected_df["Category"] = expected_df["Category"].astype("string") expected_df["x_diff"] = expected_df["x_diff"].astype("Float64") new_df = dhpd.to_pandas(new_table) From 54f152d04eb6206a68f9e5989133d72bddb35bef Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 Dec 2023 13:41:56 -0600 Subject: [PATCH 2/8] feat: Table hooks (#168) Fixes #112 Uses new python threading API from [here](https://github.com/deephaven/deephaven-core/pull/4949) This is a quick example to see all of the hooks working ``` import deephaven.ui as ui from deephaven.table import Table from deephaven import time_table @ui.component def watch_cell(source: Table): current_table = ui.use_table_data(source) current_row = ui.use_row_data(source) current_row_list = ui.use_row_list(source) current_column = ui.use_column_data(source) current_cell = ui.use_cell_data(source) view1 = ui.view(f"current time: ${current_table}") view2 = ui.view(f"current time: ${current_row}") view3 = ui.view(f"current time: ${current_row_list}") view4 = ui.view(f"current time: ${current_column}") view5 = ui.view(f"current time: ${current_cell}") return [view1, view2, view3, view4, view5] t = time_table("PT1S").tail(1) watch = watch_cell(t) ``` --- plugins/ui/DESIGN.md | 19 +- plugins/ui/examples/README.md | 69 +++- plugins/ui/examples/assets/table_hooks.png | Bin 0 -> 63712 bytes plugins/ui/src/deephaven/ui/hooks/__init__.py | 11 + .../src/deephaven/ui/hooks/use_cell_data.py | 41 +++ .../src/deephaven/ui/hooks/use_column_data.py | 41 +++ .../ui/src/deephaven/ui/hooks/use_row_data.py | 40 ++ .../ui/src/deephaven/ui/hooks/use_row_list.py | 41 +++ .../src/deephaven/ui/hooks/use_table_data.py | 148 ++++++++ .../deephaven/ui/hooks/use_table_listener.py | 9 +- plugins/ui/src/deephaven/ui/types/__init__.py | 1 + plugins/ui/src/deephaven/ui/types/types.py | 9 + plugins/ui/test/deephaven/ui/test_hooks.py | 346 +++++++++++++++++- 13 files changed, 756 insertions(+), 19 deletions(-) create mode 100644 plugins/ui/examples/assets/table_hooks.png create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_cell_data.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_column_data.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_row_data.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_row_list.py create mode 100644 plugins/ui/src/deephaven/ui/hooks/use_table_data.py create mode 100644 plugins/ui/src/deephaven/ui/types/__init__.py create mode 100644 plugins/ui/src/deephaven/ui/types/types.py diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index 6b1020cd6..ffd61aac5 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1458,6 +1458,8 @@ use_table_listener( ##### use_table_data Capture the data in a table. If the table is still loading, a sentinel value will be returned. +A transform function can be used to transform the data from a pandas Dataframe to a custom object, but this should +not be used to perform large filtering operations. Data should already be filtered to the desired rows and columns before passing to this hook as it is best to filter before data is retrieved. Use functions such as [head](https://deephaven.io/core/docs/reference/table-operations/filter/head/) or [slice](https://deephaven.io/core/docs/reference/table-operations/filter/slice/) to retrieve specific rows and functions such as [select or view](https://deephaven.io/core/docs/how-to-guides/use-select-view-update/) to retrieve specific columns. @@ -1467,16 +1469,20 @@ as [select or view](https://deephaven.io/core/docs/how-to-guides/use-select-view ```py use_table_data( table: Table, - sentinel: Sentinel = None -) -> TableData | Sentinel: + sentinel: Sentinel = None, + transform: Callable[ + [pd.DataFrame | Sentinel, bool], TransformedData | Sentinel + ] = None, +) -> TableData | Sentinel | TransformedData: ``` ###### Parameters -| Parameter | Type | Description | -| ---------- | ---------- | ---------------------------------------------------------------------------- | -| `table` | `Table` | The table to retrieve data from. | -| `sentinel` | `Sentinel` | A sentinel value to return if the viewport is still loading. Default `None`. | +| Parameter | Type | Description | +|--------------------|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `table` | `Table` | The table to retrieve data from. | +| `sentinel` | `Sentinel` | A sentinel value to return if the viewport is still loading. Default `None`. | +| `transform` | `Callable[[pd.DataFrame \| Sentinel, bool], TransformedData \| Sentinel]` | A function to transform the data from a pandas Dataframe to a custom object. The function takes a pandas dataframe or `Sentinel` as the first value and as a second value `bool` that is `True` if the the first value is the sentinel. | ##### use_column_data @@ -1601,6 +1607,7 @@ SelectionMode = Literal["CELL", "ROW", "COLUMN"] Sentinel = Any SortDirection = Literal["ASC", "DESC"] TableData = dict[ColumnName, ColumnData] +TransformedData = Any # Set a filter for a dashboard. Filter will apply to all items with a matching column/type, except for items specified in the `exclude_ids` parameter class DashboardFilter(TypedDict): diff --git a/plugins/ui/examples/README.md b/plugins/ui/examples/README.md index 325dcfc7a..18dbd3c55 100644 --- a/plugins/ui/examples/README.md +++ b/plugins/ui/examples/README.md @@ -710,7 +710,7 @@ t = time_table("PT1S").update(formulas=["X=i"]).tail(5) monitor = monitor_changed_data(t) ``` -![Stock Rollup](assets/change_monitor.png) +![Change Monitor](assets/change_monitor.png) ## Tabs @@ -746,3 +746,70 @@ def table_tabs(source): tt = table_tabs(stocks) ``` + +## Using Table Data Hooks + +There are five different hooks that can be used to get data from a table: +1. `use_table_data`: Returns a dictionary of rows and columns from the table. +2. `use_row_data`: Returns a single row from the table as a dictionary +3. `use_row_list`: Returns a single row from the table as a list +4. `use_column_data`: Returns a single column from the table as a list +5. `use_cell_data`: Returns a single cell from the table + +In this example, the hooks are used to display various pieces of information about LIZARD trades. + +```python +import deephaven.ui as ui +from deephaven.table import Table +from deephaven import time_table, agg +import deephaven.plot.express as dx + +stocks = dx.data.stocks() + + +@ui.component +def watch_lizards(source: Table): + + sold_lizards = source.where(["side in `sell`", "sym in `LIZARD`"]) + exchange_count_table = sold_lizards.view(["exchange"]).count_by( + "count", by=["exchange"] + ) + last_sell_table = sold_lizards.tail(1) + max_size_and_price_table = sold_lizards.agg_by([agg.max_(cols=["size", "price"])]) + last_ten_sizes_table = sold_lizards.view("size").tail(10) + average_sell_table = ( + sold_lizards.view(["size", "dollars"]) + .tail(100) + .sum_by() + .view("average = dollars/size") + ) + + exchange_count = ui.use_table_data(exchange_count_table) + last_sell = ui.use_row_data(last_sell_table) + max_size_and_price = ui.use_row_list(max_size_and_price_table) + last_ten_sizes = ui.use_column_data(last_ten_sizes_table) + average_sell = ui.use_cell_data(average_sell_table) + + exchange_count_view = ui.view(f"Exchange counts {exchange_count}") + last_sell_view = ui.view(f"Last Sold LIZARD: {last_sell}") + max_size_and_price_view = ui.view(f"Max size and max price: {max_size_and_price}") + last_ten_sizes_view = ui.view(f"Last Ten Sizes: {last_ten_sizes}") + average_sell_view = ui.view(f"Average LIZARD price: {average_sell}") + + return ui.flex( + exchange_count_view, + last_sell_view, + max_size_and_price_view, + last_ten_sizes_view, + average_sell_view, + margin=10, + gap=10, + direction="column", + ) + + +watch = watch_lizards(stocks) +``` + +![Table Hooks](assets/table_hooks.png) + diff --git a/plugins/ui/examples/assets/table_hooks.png b/plugins/ui/examples/assets/table_hooks.png new file mode 100644 index 0000000000000000000000000000000000000000..a57ceec1a435f117684ca778b7d2ea429fa6c635 GIT binary patch literal 63712 zcmcG#XIN8B6z?0l^j8o8X(}SUOYfp0g7n@60@8aAA&7_~z4s!$_ud6UuaVxRgwP>C zsL6@c#QY7CYPlP?yQ5n%TuniJSsX+nAE_X1#Lxgkv|&$v%whN z$X($5pKJ2_+Y%h$|GAFG5y>gf{r1GZ_+Rr`D2FiNfA=N-Ncaa*`~QAcoI3=0qA~lw z#w8bdMX!N3{J;8PJHN!UmpA<16Hi*plXE!yKV$xD(#VSc|C1i5e-NG!@JK6}Q#}p3 zH&LX9l?#tOxqICaaFMkOQVas80ilM|s<5#~TgNxoa;zV7P%trnj05ML(_0^#eHr5u zYV&*3uFl+Vt-ml2IP%Inid?#Xv%JlCjiI?IQsHlTk?B9o>2?t9O$HeaDpMsWETA=) zJe2V~uE&6Hi&lIX5_U2@R^>8m$v?`zD6Z@pzd>|w5EXv9rBHFGYPrm2PMNv|k6eDd zJ?K!Mz5Qf|GWAjM7?*^ayL?ncF~;qr zT4h0#U%eXo!^*LlV)S&+_J((cHp&9|QA~Y_av80Dd=4f}R3+`&gSWDx+{&Y+?=)_% zHfs>YtWgnG!obOMrL%yMk4^8TtW#&BF-63flNzZa@l8+RLZ+srXMv~{F+>MyRT;G^ zCCMv|`Ax0!L`5p}gI8|7@s=(-$}SJwu2EA}>iDb5tRg_AYXzZzFlM-Lo4s|v#~%o2 z*wJ$80-LuqW%7Ua>6Go{nt@Zdmmq9(QKbBPt;W7aPIVd`d)jj5gDr+Oj+yD;Gz4rT zj5%pF0F`;Z8NF5nQG3G&K=Iv3JX9kIl)a~O>5x9)ga7@=fsLn zL5pp+)FS7N%%&&x-n$I#hwh!;mFLFesC-f{`|FY)+%uWF#iCApT=sEr;VnxjdS|i^=#5e0$ccg}pEaf+Ox!8@@t$(wPU{dMLs7a*tKfxc;qjZ`j{js5UVzZoSM`Cj zsB3tt+?e19BjQiIx^hv5r~7%$4Odywer1PfQ{v(JPC87&HaoL%+3_c!8#?8K=tK;2 zSAOrDCvFviecN|VQIp#K<0#e?N^Y9H)O`AlVY-6VMOnv!xOpcf9525r0mi;$oEt-h^0rDfA6|XMMxioL*X5My zSBQ#2D5W_nge|7aBB4SP8OZ#Krg<)>+70NZpn|dOTb|_01&8c~d!6dYw7gX6-_1#xZ}}|XJ3jLz5;ZSyE*Hm=>k9dFganetGdpkxalVd+ z8p!Y&?)kwaoeC#TkwGmBBbHpc)NHfB@6PQIBlrAW=avw>A<_Utj~NlzTUwKpqtTrA z#^!++Z~ZT3lJW8-x@LBa3&mD_d1Y=7V0P#hcytYE;pRZ}&uiken-!Zfr7J`j2{pBMdAc2s6y+3mnK`%mYM!OWq3 zzy>!LxOm>+LTsT4NdvRJU44$n(QUs}(L`?E1C5ruY(C#k&bG~|e#h=wJfSE>kbwQ8 zytHdADJ0bhjXnxI4TZyK%|S`b{=2~dI>tY)9gfPH^g<{7*90D6?ZIsl&8^eYZEYwVIA4UHmRf2YTVw1R?Sf+ubf z4RdDXOOj_NtS`UQ9)50Itt+Zj7kGQ2ol zJ8F(iI(=im22ZVVQQ*i=-g*N;>Xs7Gb`9JS!Q!B3==^X127x7q-x$s+PS|HLX4i0Ii#RNO4bZvNbHJ8VOB zB9`kWemt$&vQ+s-z43Vz)oc|NX&-L!opPK$Y!p1g=o&dPpS)jZvgidjwQ>Zf$0MVJ z|Hcbt>rdRRe}|N6t4oMvEN&;8KGqEm-7)4^Onm!yp{8Yx+@0luN9K#?4!K6*$MKP7 zo!W6ZRQr{anb2q+@=kpSH18V{`HOsxd^AdF=5mG^l?=|5YZY(5?Pnn&Y^6 zAr`)Wg4>MKT~F!x;C0(^Z9v-Dv34QBj_K1C(X-ie!%n5A@lJS@O16QSe_gb_7nN}? zx^ZII;$J3nqD9}DD6)hBq9o|Biz$mkaqh7!IFMh0k?^9xU+R~^JG)y%*ln=*u>_n8 z#^y3qIUHlx{9IKDvM=IQMK>`m;R-sRnx9+b?LkoDzyQZrGSu!`nhG==)lrRr8)k(zUhs;`9BU-9dTyh>;7F7HnhbboILt- zjegF6T=c05#C;P(-oh`w_9Y&F_ymxHuYo7O6_9M`o;n}@Za|ksH)S-oGnTsrZ=9i` z=~Gem_aRAzMbcSk{+)Cc$N~(`{=K6TijZdB@|3r(XYQyqlnd zA6Yz-PcnSP?{2-ul?sxA$bN28K-{u)k_$Lxa|O%`M6XHQ*bHU64Cteic&=qH{ZT7E zVX|LcT=FP^WY{2??@*c&J)+i$1j z{ExPJA=_iq=iPkh(%QYB884K)G~OkT5B@O59%<5a(yTQahr_v*UTzx@7wVgV-&&t0 zz2N!j`sIsU5zlVd*|17(eiFCDN(iMm$H!!*``wI(L+`B_)q!{kC8ZR*bttk9Sh1rC z_6b6nw&%o`~b{DN2t3^_8 zWlhj$1`GtAx#4fM;_dv0FEvumc(dt8anrH&_h%l9+9=(c@8*SoPVo71-8e;OJ?`I* z9#SV&xiK=Fg99-EN}TCwBBdA2ShpoCsGhnWw-CdNm*}`wqr?mr=e?+6Vwv&@XiH}ppV>-CzbBcDfHyI&$%7@@cHoUDZfVCuT60g$gOVC0vXi9i*2m(e z-a|lEMf5o=I)vqj`ag+QB^JLIJ2}5C9j)y-?Zy{&$O7pl&XOKLxt?!r7}dV&r17Xe zy^WkR|5-Zak;<|LMtP0WR^gmV30G63+N}6;+h((XPQZf+y|nm)uWek~x2zGA#hM#E zN)+ztyp2&cI4rtZ4R}D`m%C;nmT!1)Xf+z)>oS}E>k2ecXwu81v>{{)nP<_>z@I5F z&714=14Pz#gjUTTO2FvL-7kATB_7!O=Gj|22PPxtL?@fObeaZ#4H~CYE(ZneCVhB^ zLqw1;MG)L8&ZA3QdEVc#*m^LnGVN)oK9*;c>tXgbGK0shR8qV!Q;|~k)lPM{G;*Nk z#-M>MR;!0v+*C13UFPDA&Lf@8Q{}ixT%e+MRf+$$MU&%h1W-xkl)vNr^+AxkoH199 z<8|jq|NT}T)i)Q&SnzF=d@68MD|6r{fNf!RH|s;pS7$AG`f1ASw*vw2uH?~6<)(-b zr%5(ZHD<>@ZjcaZ9Z$d$f19DBAu=IAWQ$EU_l}3Rny-m^)3wSF=VYi7h@8oiHEEQm zPFc-lBD*yFNj5%}#vHm*>M(8K(i%` z2Ydg35Ir9pB>7t44^GQi)+YkSX|;OxTtn9y_^|ngM1Mlyf+lHaDQy;?=2213_z<00 zz(t~KOxD@$-Op2;$-=RPQXZrKRxDO7>grOZa0Rx3Cc}Ri>nysj#G5EHPds6qRBbn$+;G5U8>dV8MFUo`-rN8!B<~$NR;y-J~F$^4G%$`8vzoZ=+cbHAJQQ5Dk zGqV+S9$gfq#7W$&E!V{TxvgP^x`pn$JRn-LMRZOjleN)sBoA6xYyiwCrCoytnUAdz zu&s7S2l2FaVcEVnmxuS^Mtzks*8I_gezyO(Gub86r1D%524B&U9lQo*mE$ zK5zqhK&ZTY*`BVJ@G8Y{PFR~`SqG`EQ zThuhT*3uSxj&bpE+5l52G`1}@H0G(I?j=+vRQjIFzR8gAu4_4zd=BYAEWPNpNeRdY{#*s=P)qxlOYz{}w5xCE5q-#?KfE0z7zS&-tR%#sfnP0PM2Pyb*DB&8( z(|0=>I!_VMl4sklEn?Fc)M$NztM9!O(bvVFk-Q;9hYtgnNJERR+I<^UK#r$J^OFH6 z{T@vz4ZTd()!Ni!p_AGg3NVjIDUGroV-b9n6BA0 z$`IMC1iapIry69D1a#Px?GV>hk^cg*>Za51HepV-k_oQejF##oFsj`Y ztcnz(TQ_j}-0%p{3;^zggps8M8$%za@E{lF^u6*cH6Qv**lw}3V~*aWTk7KO4E4xx zk<*{7<52yl`(buW?UDN7Lbuf>uX--=FqaJ^^;!>D8tBpR%!H-({rED7hc~9=16Rj0 z?GeVsrUT0s;^Tpc`=>X!zV&`VgyWY0x5^M77v9`;DGeL?#rYSwT#vr=c+HtnclUvG zoS zq=87VevWDv8{OQ19^25H)Coq37+heqwM2l%>+Q$IE55yvcpQ&33QYlF>YDvPq9{8& z791szwsIZguzqX-n^ z>5gmV2np?j`Z`ruxG-l_gO;f|>h629xElAt>1Vp*JaF~<=n_SrA&s|{ySk~g%u_!8 z>XMqXfm!$G3a=l`xzvMv9#cQFC?RbN7j(8tO|57Fx`QX21^0p*wFu5WZf6VIkx zaEfl~8JTW1>W;;_xHx)QB`r8_i?YGroy6mXD!)D1dzfI@`Dh)2|E;8thuf`_?np|4 znn#yeK{x4Ys5u(8oou#fCmy=2EqbGE5(p3@Th4kOr3hK)0Enp>>AvA?p%{LBT6gL& zA#+pv2#WBp2nNcJd?)J%f5!#!91>@Jfdw3QGN~;GQYW<=-{$M!09|#%UZm?Q)QR=D zcxXLaPdHe@b==EQg_MK{dEmdlYn+;$xBdbNXA{gBrS@R+

K98mD>~cG|ZY4dot-MxsD=`um6C?VY31Q#O{?E!c`iyod~3EX20Jw% zhjuShPKNt?-hG+}2=YnJocN||k`o}y)HVI2=a*W>uR2ygk$NT6VCWz5-Q%S)cUpICrT8>o9s6U;M7|DpwkH!vtf07Lf{+cO$L9NnY809%AQlQ20&{*X5+`nMdFJS7C zp!0-VImLGd`(p027aETPhV=|2;sA}>MT z(irZsOOimmzU18C^Z_wY{NI8`1@CRxOM-;AKvpUIbnJTEo6GzytydTz3>tZqfu>0| zX9g(wEyw+2dw*M0`Yw;@seD6@bPhx(SjS#m;prC`nMT9Z%bKp=uy^2qFUkN~d;lf= z9CjQ{iIJ|cC0}to_<>k$^k!LA*LNbEQp4i?(I3LR0Y<|T$hwSQRPH{6=}FY5jJ`GI zs)^P+JlP{cDKF*JjY_U!f<3x3@r%ZO{?7n`s}e_RyXT9!OL8bS2n zm+comICu4!zzUgNq^=&Ntb5K+{au z8+9pNhrFN*a#TF-@CS`bWQ#16iz9w_suvK!-<1Ldzj|3p9ZGZsM8v9*8?1ydGO4qu zA(3a>(<(eP#G0p?K+w3H4-2)YzH& z41avS#(sf0gpjB?UwcvapgIaqj5Z+0 zCCu9im8l9zbnYn4=4*fRe|S323wB_j%<_Z6hAH1*juu?9? zPCS+?R^&@5V`f<$Qrk-@!84W^`FF54>N|TcU)RC3D_|v9s0-*O zN`S|dHA=m8V;f?bvD$^lfx`x*SAiD1*711^EuaiYl~&#z;v4bbuj&BvY%iq0B>hbZ zg$HUt8!QJHd28m`fr@)+exsM!ftS}s*kr=*g)LQ1yZ>r-9^#$TRHC(y-+mY?0wxXN57&`@hAJw>!eV8Q?mqU)@N*YU3o-ur^|%JIx$; zzTp~`1R&5&d=wzALtciwtmmrc*{{Im_ej^#&=bAUpPtX zA<&pnNR^i-;*DMECNG!V;nYk2Lx78V3m6)T+M~%V*Rxuj!meZO6jnMb(|VcPD&gzM z>_{=_)yb~$@?_E96|1Sdg_I#{7xifpVud?NS{oVWv-e8=aB<*O?k1FaVk3>bAejgH z3aMNw7#cm2mK53`4W$<6dUXff&woLkpNcRitsf69coTwf|6}VfyEJEmQp@r#ZLavN(g)n_ zb#@H4k_?~xW)=rS1(jI>X0l$SOb})9!sDq~`Y<0(RBHcFBuo_u*WINR@iV09U_H9u zJuW-(D^7Lc|J3odyvN@ythkeJqI}PMByv2ca4Q12Gl<^8AE8(pg8OM{R1=eWkN0gKrpf27JS3L7by6pE=$z#DzO@6358v{ z@JzPEt!Y+gz5c{87suvZ#YY}TguzS=h18Uw_7+vR9bMS|-S^Q`$Sf6~(jGxU zh6AJ)v3sO+V?4!k0gl|oKhR9Z=nMqY~mxL?&^X8k*{ONSs>uP+XM6)Ye49R2zv)5^OiMGGKZ|lhhq0hC|$^G8DmbDra06oADNO77sh#HVsta?X*6T|NH4~hcg(NmZZ>j#7r4K-#dS# z5Q94`4Lt0rI7@q|TwdTUB+PtRx>~~V0V4gUpf*$v+S}P45}|fA5TqMl_nIK#lv7Wq zk8Q4T@wU27rgcKBc=~k8GPTHbY>ZndX&>f~dDe-QqvJAjA}CLd$aBj5)D0E8zP1to z$@;01OR3^=OS9(RxbO!@&Wvk+_|WM(&cRssweLL+kNN|{Z?5Wozm=hRzjTCLoW%J7 z9IzeywETxjD zdeL0UT~OMTAf#U_j2(?n&@)5lXtVtkn0LW58FDeSGwoc_H- zag3^}Gh7vT!=+KHC`1F$A=4EcExO2u9h?q7%BhU#Ijg=-$wnWqyHty(zZ0m1uloA)%u{ z^fdKc`-8saL!e-D^FJ?sz{30C|^u*&gfNj z?Urikedp2LDh*zAxnGLcz99=RaQrb@vd1mPs2^GI^RQFU;Gp=Zw_QEDi)#+1IYViV z$H6cgsa$)yn-{HG-;t|!Wb4B<&%9^Izcf0*FPbF*dI;!?PBE`pTCj0G zDTU^*wdL2%Bpg1)DR#}0od?UiTqtz0udAcU8Ebko6JkTnfu!v+~dCK2qBd_ zeK2KpX$~^DXOzqD`l}CluHKa#*gBZOmO{0|^F`P;$-Po%+&v(c`W&KuO8#=Tx@CB#rjSgcEdA%esKrub4tA!wdrrl6sx~7NDdY9sYLf`jzziCC!uS!sJ7!M$rx9qAAN}*G_f;{e81LT_J>4APCYTcGjJ>jW~v#TbI|pJqqF-^#yC6Z)G#53{#()41CLJ@ z7Xj$eL+#?bb+4 zJ|GkGh@Mu~e=pe)a(yEb&rRPJXtfosQ=n(!9BPoJ{VdJI4{cmhiMl{`5l!LIwDh|F znrD)~>3C4h)Hne?g<{r5PpOSZHv4O;X$maA=_=)z}9y>D|f33=H2q~R)C#(U5A$gH8UkTpuGWhpWjQR z6jyS-tEaqca;!6O6FE$PrcMevQpNXg_wgZ1Yn~*F$^~)_=QYEP$5b{&Lasl3t6&5M zeAcJO3j3d&8+!D-2><`X$6=iQ?;&vix464o(n%iS@i>3=HK{R4SUGj(emEAGTY59_ zGT<^&b8Y-s=+mq5F9@72Vp2R3{y_*_)C2^7`KQ*_UiAmlt%?&B?)6 zGf#zB_83?8S(fGcryyA!UF?^BW%rm6L}h1*1-;lJ+RBpR4_&by|1!9H#F)CNRm7ax!0p6bSCK$fnKIArD+r{Jy1brs>Vf zcxc?WO15~98^To;FX`gECvb9CWENMQ(+{Vs@4ppX!|nL*gpO}cZzNDjYhLy~>i53) z;LQqTp}!d<-L3V=PoM1&t5dNd%Bniw)0+BzGV%=$-8AsJyWy4}TVtr`()8a>Ooj#b zQVs@=iEE|@y||zdkf|9(oBmOI=T$mVuIBq5V-|R^U4M~ej4@VFHqF8iPh+s>!y=c? zd#=M)7+6t!Gv~G7<}``)Zuh{D!NvuGvp&l$lHI1b#lesL19KY#Kd#jd zG&@rrR@U1x9(o!YL)Tz*Dhve^;}YH{6-Bopw6I3s<0kKA{Y59SwLLE{E}?Q3e)!(u z`HT2pM4{#vx1NEhKX8E!WS!Jy6bfXfyv zX6N)DXp%*7-ZSsxbQlXbhC=TAr19=HZ!lxdIG|%4(FL0%*_aVjV#=%>OI>`8 z`*IGMA^IxK+>Gh`$2t~Cy^9>KWyoX(tp|HzgJ}YF)RS8MosC<&u;3;2IFD})y2&R6IpUru#v=+Phoiw$FmjS@dNxoUgTzS3oMgc)Rw`tBXs&_>oN zen39AB)}9fT7y`}qg4BcU3eBoWb>8R>^%`g(+IKbS>#5HG=O?@Oo>9XUp)q`NGzeT z8rvfje@EZ66MRtJ!K&9w?7br4aca`doYoYjtV?>}$Pf067z)Yt?iAyWAP<3op0-2g zzyo2=W1g}O*s5W6vJt4{M9DO>Y%d*6Yw9{xuR^%Zp6#jiBrX1t?v0Qc1{Q*M$&wX? z1&Qhr&~)g1Xj3REjK$x8Vp6HVYtE?1qkKDU>`Z2W#mD4D^k41MFw2O(3>FzgAGKA4 z3SZ+Jte|6P-Sc)jD+Kkx?5KH#f_m4&C!eyhVNLdykfmwWvh39WkB6$jkw_gVTKwR9 zxxP|i!<>l&u3QC{3o?{r!g3vmm=FpIbN%uC?BjXx(xIevyS@=9wqMcq@hEzCc!Ylf zDN!=w_N3ROZX$8XoWEkyC6upf|Htlrt3yS4;PP($IHVyD^N*d)%(haugGo27*e`we z;^u3zlyt@5O|w$UJp!MMOF2(?D)lK2d^?hO^-*r@ykxERe)=DxmKAWBs;aJ3F03YN zZhp7tsr@l|94qs`@;(T&YXP!HjpL^Rp}~LKJ2=KcB2K zAashBy?OuSb}Hr7-*E0}Y_>f~))&+9->U*InYe<_ZE3NOi0=QAP897|wN+B<^j$Dn zrHZ2LB#uKUS4rQNkY;TFgeYas*YOGU$7Qa!s1ZFxEZ@60^W{N@B6C|9C49&|7TWx4 zMlsra!v_=VxE){OEXY3d(jRDUD4?pwKA!@Xpc~o9_U#?dx1y3y&;NmZN{nFNBEeFl zj_aL$#PH}(>{j5w;$pV+fMphv6~y;e;z#Pzr@n+w^oz{2Q6y-(TWxh&W{>?k&uoAN z?iQBnEsbx&RnUF;c06>h*uTA!fek>jpr0Akc6lc==jcI@(s&@Uy;gMKInBn+JEy+8 zW$)yrvuK@C@nPhv6}O>t!||^sK!2pzYRszte&hp=nWvhAff>%RC@VwtF7(Yv%-Y4A zS$gIiVq<-U{rh93TyuB+`)33AlU$U0f&HMufnH+av)^-EL5Q2ZvhrA2COJN$Hay8~ z!z$i`$N#((Tt=X5qId^_OX!n{Xxy`dhw`p&BEh_*}{*;&W*sUn2xX` zn(?faNP47#za+bH6zq1IZ&FY;8dH#LMkdkh9m3dmj>Cbc!HCVIyvl?gFTIP3xP2kI z2)^E?#haY*Gs^7UNq5eYz4`3ue|Vt}+fS86`#LgmgXW)ODyo`R{e=&%vCQI!z$7E@ z=TEg0hbCphz6Q!-?mYeh=pX;Vi$+$7Ggfy$d8*$_o?dO{v0yG&0$Xk+fe>Mjze9W%GS15bf^ghX$avdvyKo>nRZxa` zPWeWWup=nxZHJbF8FWG^D8VWo(}ms{@iZY^2QGW=Y(@`!m8FL@tIJY6J!tnc?&yif z;*HNbZjY7_nT)hA@WwYWe(|xnUUCGKEM|8W zdw8oF5`^zCxR9k&+0JiaNz_MfWKXS@*ck&rFIeR6&lT?`%&Uzhy|*5&=#NBv>Pyro zWJxw_PxRHf#~K2PnI+Ddt1qPCcf**A&22HvSN$|V81Cj)Sy@~f-OdJ7NUpU<%!Rk8hVhF%}kzh(cLm_c1L>pf7 z4`H9E#q;IhzGQG|e{5KAOV<$|3}jB^Qh4+r;3Y#_K~?`_BOXSqF6s)7% z0tw~+(c+cnexZrNCo<2u($Za~r;YvB{qM{zvIzcW`7K&czTLqLo@xBJIvbe?ae>*e z8zjx(e~&}B1GwQ&-#FvG0+4C3+81D$O%!S8e+A4H58!0FKkak4|`5%JS?>~0)i zK+-%M-K|;YUC40|1KNJhI=3C_Pf`u>VM*Bb_C8s5LvrWDca&I`Xk?}ixUHYyDT2%3 zHKqS$x-OsErLrac@YAnKJ}u+OjybH_B}v!F1s^u(|DqJlF*{h=V**rkOH_i*s8%dc2eS0PpO9OwIkZmMcQiBEA? z^muaip`W4*jdY*>IP8%SNXxs7Om7b+uuu}2vxfl6vau93FQ|TB&(Llg0CeQqO1sUL zy8W*;l;uiHdg-vd!!}Yhb{`-Lzg+V%*j@%vpFO`#0oKVN8`7ATxNf=L zDHrAQpWRiwoEIbZ1uSrvs&4PUv&|}_+KhL5BFywl43cP#H%XPJHx4H8{ZzI%o9vbW z-YeWk2i?7mNR&KRbg+M5?J+{I4Gyfz--LQ)V5plH2r86Yqk*jAwqDvp7|Q9=@p+f zn(>dWTq=)}H0UxNKmBoP3jOr}0{DYv#vZ@JmcLAd#U<|Pm#wU7j^9a=GWbFu7o;gP zz!$#10XF!q;j-YnB0H?mL%v-ZB80+5g*6+Gn8)dl>n3EW$M-74)~?2K%1K^Erq@+G ziK-#hTEvs*JiWwg+6Oqwg$OCMa|nhe9X|Mq92YT%2tiqLxVTqG{m7s%VHQ_df9_|( zDjudaw{vz5QzztKKHebeA;;144Wq@yndIOw0ON#9)cj+P6C8*1`sHoP(|{n`E3nLU<&g%Qa>=S&lkST)U?Kof-!Z|_~x_Mx&|A1>A5vf|bEl{C z7#bwXcfNZK&x(W~mB)*N6_vo2ZV)hfq#g&SQrM2J|@OZ_gUCKc9lFh&MQ+dUQb>5ox1ApR3u27yes?f$$ z^Xa}MbN|!On2PlY3X*1AY@Ugc5#C4e{?H@5tg6=NgzCgQiZ2^hlb}E;zDz#5^0b>h zli>HM-dl>GZ`K;`zLg4?gRTw#F>vPtHp3>QQyxx?J_j|=lxXhaJzNV1a8tv>YRHCv zu(0$r62w@}Q8(JjsdA^jqE*hm9QEi+jO+5Ljn8I>By3H&^MT9wbQI{#zXT=SKm?GR zifr_YuM0>f#X@Mn3ujq1CO(G^8%tLarryhJo_(FN=)oPt?0rRf_DV56o&uE=NH5XC z3YYc2{KJk~3Wn&KHs~@bhcI-n#sBqZ)o1Xi4TVpcnkid+7)Fi zN;r>zH*2$S!nYnU;%@$>Z{^x2x@}0@+RZ4QA{}~|b>hZlkJ`E_mAeG#seLJRYmTw< z{3DGJkth9Ao)pjYLyYMW>)mm7Ae0*}XzDfd-E%upAO953k~WZt4v|F{+=XSuv;yH> zSGFT5{jxgFW3lR|ZM)%Jr!%$N>@EC_4{KzX@~-;AcsoCn=Fh>6q4N9@BWu_aJr?&Z z)M*4yL@fW`vJYQ)Y|b#2@xOm#xsd-7)g_0B;)6YI#Awl4xbL&5G|^E!b0$ua-2zEz zOG8_8(^Z5(I&NWW`2>$^@QBh$oob0b*W23uBroVe2m5sUvx)6Lv0^jP}NxE{B>^s@ ztk+ppu4Uv?C0t*9Xird2D*iY(Oo(quL#8VLPjunbq)8Y$OFWSHR#j{1R=Zg63d`D% z`T8mlHZw+({NkXm8$5w}(#>s~tR3~d|3V*%GN1JT%$yUif`iJO;_0=yI5Ova(mqAq-$YsLF+{{d^u=%m24=xzZ3DnyYdXx zOSz`>ADdj1*3hc^tSW3GAxidaH-^1?L)xz^J*1E;nWUVJN^&%J@MLT%tBVsOtE zPK+X^+RO|0e6#!1FFy6w`V35J(QWUyi`Ny*BONzYwp*>fZkbfBm?2Wcw(oh5&3HVK zY`E_}4B^kJenBk>effg4v)7(U&7C$N&HboXvJ<6NbWVyOSpMH^xlWH`mMS6LxZ-R8G>m?M&mnkK% zHB|-jFP=>iP1?!gCg8`g2s^hLEov*TDBwUw zNjb`HI5uSjmR{16FZ)}Rg~tVVxK-9Kf0!bw{`JI^0O;#7@vIHJj=sg~rZjMpkBASl zR9bRc!hO5N?U%F;VCQn*B0fJQz}1r|>ZruEjS!25pY&4v-0S?1d2dpxa?_`BxY%|+ zCT9MPuLf1s>5RZZuQROTws)UZAK(oRt#t#|`_a5sQ>@-9wBfw;OJibdj}kTD*I-FY zN1P|Mjyf-NpPxS9z;)COAg~?jDxKByHw!qN@!TJbt=JMD>9Y@cVT0wW#yXo_Mm68I zM+9KV9+AkC38YSx4FbTPNB=6E%m7nVY(fzoI9n7vXL9+kSK&7Y%_akrj0$x<2i?1gWWPq0N!C51k+8~Sy;VN^zH(>PmVf_0 zt%2-u26B7U#sb1h!t2p>UC4m+U z-7k|Kc7j!9Au4*F4igolHs1_v+#DOwWuY518ZYBlZAL13Hv}q$2|1JST`?A6F81|<#) zy_7wJ(2hx=u-zNADpf8cy)=*0kd5qVu;uY>0*6wfJyQaf@%r9o=n$^ z-w)_a|8tEbl8eX%^!=8CJB}6|55OJ8N>f14xr**lemXfOo)Lnb;!G|M?ZmmFr+ry0 zSU{+no$~R#;)~{pRwo^6XeldK`{Eil`5Tg5Q^ifBgiNyA&sJj&+=|8dg{?I2T<4cl z0^0(a{$vB#f^({Ti#k5t9??3Ls@AevV9jND*}2PMOZdk>GLYI|#)tB$-n>0{Gk4kx zITW5MlO)lnO%%f>?Bd3jc~UMrHO5oj`YH}d>nA@WYk`t)P||~6`5~d`ac@361MrNN zQ^wNgcr}a`+rSlFPn_y*$7qm_k)yjuedhao|AFt$**QCBZ2Yq8x}Mkbal7B{ zT+1WQ>OkGT(J8=yO?jhrqbz{w zi73tHY>P$&PLP1~r7|j=odEdF*rKvO*xJ8`2jKA(rSB{74Jw{a)Mia|mldk4KQ z5qMU|mX%yqTU>pR)HgbD@)mE&+Z1)ZnMK<8a*F9HN$uz9j4%t{4DZQD@;JA)KP&Af zl?upii8l(ObrI~{_Ds2ya10P(@;P+ruOfZ%4Zcg3=Z@nKx*FyesaMcf%47$RI|KRq z6ZXZE-LLW6%xW`Zz7gIRJhU|&o0m0kgm}vQ6utT4pHg0>xNlx`q?=M{(#s=I2&m31 z2H%vOl_;bEKQ1S4a2q=7kHq|BOFaKHO&F+AZsUJ#hVyuUu*L1qqT@{_R-$ra6>-?E z(!;ma0oSu`wr>AW9XTSqNX7EwJemy~O#GJ%2PCC?DD{5CG+mIKmsRD$H&Eo2tuA8! znW%ikt7c>Q;&)W>Y!(L|;7_+$dPRxXLq!4U(NC^&SgZ9;1Qh65*%-RgS7&zcoEYO; zt4i+&?!GQe%w39bY8-U`z#uPsVj?JR35R>%4;qeH@8V@g?H20oAj^d~yqZuf+Jb_3 z{6E+e3NWIi4~2+mtp$ZRrmQu+=deh22mGJe z>J=3Si=_9~#yXf-fAT5$sRuMJr1LYuyL3`8lZ+S?T@x121Od2Jirr{ED^;bA1S_#FDcs_*EPq2){_e5>poc zch9Fi)%v=sdFN2M(d?rU7 zn?8=ndUgBr$1E7*tSxX2P!rCyL60qrMOEJd1^XY3Edzo zT1mYgy2itVoEM^U7c;plT-z3n@T&@nxWS-U;fjC6qry1OrifssG~by^$A_wmqi#2r z%s5yhIl&X@ixB5cS`WN~=&HN;90=GuU#p6Tx&X_2y9FnDxI{)dpKl<1B)jwCnfmCq z@$BY%z6%a43HM=f2SdHG(wd%`;=~_6-fF3MKRd4Zyp?G-aK24Dsl;xk-@?=S-?bcI zJ>^G~FOO)Oo$5Q+1tN-D0D$msgs-3B*kP4S#k)hWzhUh_!*=-<;d)JV?#r~uN$%`H z>k$#Js+7c85jJL^aZJ&fslTs42!$P(rfTEYR-`{Mcdj64#0dz}yIaUJs&vLjZ23NH z+<286)?y!cbVv^ru7-fn>OQ+6pET&mhWfFN)X2^hgI_42s$H0qJ+EcZrb~m_5oC$& zLOtTsd+zw$*j+~>!s_Zk(7D`v_aagwfdo-1O#CHuCBRoAi**oq`b0$m`=+fc0RGFuax<)7B=-Vjlc@g?1a^RodKHlmc*cnDvl)GZBh9|2umkQ-t6qpu8y4vndP;&PkA964i4g%^9>rK zQEwFVNUlsretXT?7{P5nqV^3KYh26uZ1>8=}M26;co?>$%{D*tiQB7iX$LXpg($P zL{@jDtu6BYCi;H!;a-O?0AM6dvTz^~B$6a@`}QcguWbR;=NQx636j0nP0iQG15iV zI}I{vonIDp;1_+YQUbQrD23tj(Py<0-~i!vV(pC}bkBGqB(SHHbm09S(R&m1TwXg@ z*viC-%vQMUNAxo%8QT;r*bPSZ(qc+e)r~FKO%Gdk+OsnHQN_2BaedPJF6v^|9JuYQ z<1rXQ*+#|cD4&#v)5dX$87Rn>3m$@ANSsh~2}(W$rxr*>s$;!aewP6Eb~#lHfb+cv(7;kkwV zRiC!^7H^GHx{NMl&Z6$R#6RVQ6Q`M?}xsp0^N zf1%)0+&*C%tn`VKbJ(nZ$7|`o&5wT>?4Gf23EP^}eF5z!^#`gcCZ`An5HAjH7n?Ifrys zyk%Omxlrc(o`?FHos!l@Ex$=?g?89pTQ92+KeTE@P-?K4g)I{6veVXy|2 z#8uXjtqkz&EVpt;LUMC8`o`wlDwtUR1jb?(fzK2E0EsKll7grnI>Mrm_w?=MWF*hB zK_>+A9})1^A|h-9s9{G_6(c<)1!zVdI4yAv%u-52Mn3~@Of&t~%`n!RMAU8TRWx3@~T zwQyj+3pkba!}CRrI1_$s*%yk3V#1o7XpzekZ7K5FKjd=7TjXYrkt9@vgUFLQkaPc! zj?hVZ$f@eZZ*`KrNsNFiErd&0xMXaU4== z7{WJp9BXv*tNu1pc96NXQ({^zzMJjFcvfC&^!aqULFUeKIIJ%&3vcdyXwwkm)CM{uPv+KT0xsBqWwPN&q>p0Uav-`F(u65r+_AE}R6Rt@y z8%B+%{KzGa!;Jgm3~xVjwAzLk(UAg@n^AQQa4@-9@+ZZF@b=7BOu5Nb_rO63f9Ug& zBw-V9v0RJxIf`FI-+Z~h0TlG4v|b%1&_A8O+N(bPL~8py8+StZZ!4{i?x(1=9tZ_J z_S~%TL3H%$$vGQf842AxiDSqlpvh$Wg`u^?x+Yd0Mw^x{JyH5zs>Y8Q z?xvA{PSumK5QFs?;+~q@7T~_lH?E)Wi(><%5CS~069NP_3Q>sA03>b6#;hBEfrAOS zULjWr;e3KU#|!QwuRVw#a6MrZhQ!?N0n~|UCP0vQwe-l_?HGUOvl_FS1IE4MqY*bZ zh$9~6ZnbSn(ag}?O`gXMH*vd!2oZ&O*4XOMb8(w~wwn$R`(-jb7jLfj%Q#DqgkHc(jZpbLXUv$79;qk45n82n!yFc*B`~hI5g~_xD~4-5T(3?c{g#L9n17 zDjMlycYd;tN!^p_h6Sw8P;z&wUT}Q7nqTN^xnelQd(GkFKX2gVidhe*JR{7M-tDGN zv0BT333j(L>MU$K zvPE7<#$m0O8XSr$X=P2$a=b1uz6I3Hbq5uf-jOa~opL18rFRK^{;gEN7yYv$f;zhh z!LoMXT^$%Pmg<+~FZvOg`&kokonZFv9w_044N;z03%N@kAZH5!CX!R@{#xE75iHC$3$2L(W-{wQe3=P{JS z1?=<=C~fwi^JWjGV^O6Lai0Q$ybW+cF`dtrWOvx&zX-3_>tfL1XY}NooavQaC@_OY zX2JSW7aw5m4`1^m#sO=}KIDYV@TFB9m@fAwBlp_{$aLDe;le&3;WCdVeC5fF&Ymdu zn@?rpQJtJ_cst*r#crktV-+nQH|SLV!)~bJ=Qs^G_h2+y#R-635>?_@K&#@}2!9_? z%1Bq725^QwznZfJ@lK6nwZKB7qrN z6}}|5<&@7Z-@>8)1TCf%KQ})S_S$dgMT*A|k$KZL#aFA?2botrnr$DpJQ8_&CVkPF12-ajk(WG6kkDrZ<*7DCCfC9T)Z zv4Z5bzU@Yc3YBLCJovSUNA)6kTmtQizBSZ!C*(i%Mv%0)7oY9ITKk_ARY@KAbNGUP z{eC9mu{M^>fm>(36oR)NSAgWVFlS$v#pG9!pzC;-O0cFQ$hoIo8~7R6R%Nt;A@6jh zegCT%ZcZ*E9xY}s%dgD>QND~nM|vYxkBR+3KljC<65KRKDu zUL2fWQ>K^5tlo581n%}!L674t3~8%m=>(DE<9E?Y;iW3xl~v;Xbu(X+0wt$?r&w*z zYGfDBBtBZR2nLv4y|6jZg#9_>3RKUZTfXf$}i|*g% z{$?`SW`saO^{13FlJ4@j{9aALR!QUNQotnqu#^-A=-OYzbG;rbW<)zkQQ25!*_hZ_ z_H=*?zg6qFmtaw^KM{X+G1&_`xQ*)a8IX|^uKA#I@5zGv^=+A1jvG}S0+W57Hu%{)CF__`M9rX~H~=T_1d?DMHZuX5K% zAin&&^3)=DIcoo52YJ4Ws4a7+jBVzAPXPJ-J?jmxEw6j=#bX`3sVk!^>rJ3rtbWs; z)>nxF;i9<*!82R2c+>B1N|E-OzL98)X1d9;7V`5pk{0meA(X- zD00|l5m0viZbTX4Y~%(j9@#JKEB1%U#h^W`?#eHW(1KqqWyA5UGM)OXL0%wNWtt81 z2ikj2vE=XmUhJgBJfGDk0Xn-fzmxzIq9ZcYMk^mYE#`_1jPQi(t>pv6elZFfCOO`j z4p!O*Xx_DY8hS4n*?26LWPw#>7bjeA_Y04TkLrd+|2jSs9S#3Z8M$+3#5~z+d^K8B zLCrAPi-Vr*mU@u3?lEL>@_OX_oy<6uNhoNn4hkYhh2&ci&{y z1s+OU7jBE)Z^}+R;QLs0jFY^z3B3Q5vT&*-3NLILe_1gA9Akh*gL7VvrZ4>ex@Yg> z&rD_v>|+AH`iP%R=c~XO(A9d_i2JuZhb2WU;4OL0rAng!`^ z!-+zO>Q3{Qebr1Eg^np=xQjsfz3bK`ht9*7Sr@O{yJt(!&VA#AC(1-FPJHz}%D8r7 zxJb2@&YFX&JT=7>gxl zKWG_lG$S`akV&TMn627gOWzrz{~upU1y*^$`DDJdD0*tuVbSZVw11vWYHY z3b1g?O{biDwabxFMhm$(Th#`u2~O8n7a1er_mg-Y<5NGvnI32TJ@yFtl7(8A^(lb5 zeeip^E8qX{l0LnHKg0X>_!ty_Qt6XL%9};;*=iE3P2@N&b3K0I5%Wbcdq*cgy*>M? zqWFL+4c$d^;$=iu?jdtl5WO_%(&!hxTA^(fEE2Jz1j}`87F$9rI_+$)RX=LIXx-5$ z(LPcqd2bS1F3%yt~jgKS{%8Q}la>L)+0iSnaqzZ~^rtatr0 zDP{fTnB%xih{(X}2%Z!*>4gXBEk)lo%l|yeb_JZDW(38qp&6A;!W?081HlhBB#Y9Y z+rqbx7sB5!2KXmuj#M|$WI?`k)hlLld9l22-T0lp8qzICa46BZB=P05<(lpGa6*uO z@@}(bf&UeArd;x2wWVLdj|_-*$>M^fIN<7^pm8MebGH-t7c=Z8+xuAy_}oM6@<8oD zE@|}!^NoEge=CY8TCZM={&O~k9SGM9jEJ-Kwk%IuoX#pYlYh8;&A%+;@Z{6Rw<|KM zYc-F%Ca9XU-0>PieJi!8bB#Y!2RLj0c$SN0S&p6SX{(3%mFDjr1(l_8rYxKRmrBrK z^fM=laPk$Bm7P|hHq%+UH+|o`g3b4+;-apo;m{{ju{{H&kt>d!}ImzZcF0W|HM>el- z=gk~4=(A|decR?%Scb~V(+jDyQliKm)=HfG;-gCa7-s5U9m3XPjt&)4cXebp65kuV zId4Cj(3bUxr@-SSA_hI$p7>AB%CL>)M(X3$(dW@4?1_JJ+l@~j*_*D|@lGdON&P%g z+i|jCp-^g{DlVC$p?OhldD%Gu*zt4jHMt}l6SF=!r%!`=s>*MVGX>T|s@Hl0o@g7)M~(QXtmRfSN-9cm6}CRy z`yRJXxQ|G2Sz%a`?bDH-Ue#WRNKnwHTgK({X)9V54QjhuN<|R{zIOD*62>vSm_IR| zLY{>U-uQ_{z^x-DT9^yj1f?kVBe=3MBqZRqpuLKg3k~k~{mZ+7#`1H|+kWp{-Fj*{ zR!5a(2+n@9Eqg1Y>L*%;BRh@RcgU|=4nPW={_s$&S#t1jo;z7t!t4skVMUMl`e9I7 z$1ZA)_C2Lk7cH^6K5&3}wg556`Z-&m@q3X|03kHQUa;RC#XP1ht3`q44cf&3ri z2tPKvgh*G;IBVkZGt)MEELa-8^y7cG?kd@>v6PgD^}&V5@xSGA@bv+?s+)<($W zz2}k_L$uZCgqQT4dnQ=O|HVb;5qv$^LBcWs5(qiLsr%SzzH+<#0V_AbJqT@f%efPsSE2YGK2%Rdmt&Y;zu6kS z_LOVfa!3mQ>V;Zn$gqLjDbLxCGGh|WhlOaIOZ0sD3FPl$ZvnC3?(NxpZGCJLKguj7 z$#Y9>QQ(|f4sOdFb8X@GRQ@Ga;`JO+09Oq9HhqUBfbG*?`%Im=M_Jnp{wqb~!_{!7 z@}`%)y%W!X7yn)B4>pK${a}e^N}$|69miZO9@o7RM1;eY9;BV+dlqv}d#W#f?yevE zF(BD8(OCxJ8C?M3*YdIUvymf#%=Capz?y@Ut0r^Q?r#P#fsd3-rq{RjA-ViI>FI$p|`W0if)Z3{FQ9>_rY9xfKXYs|{Fo5ta|`sZB! z@mNm9u@Tn&W|8Db#DNj^l)0@!!DC&8fpjdI zwp!$@P{|jhYbdFIpnbB-$(aVngXP#xKu^WI# zEHL7F3LASIZ}AF)_8u!*y2rCF(;j)fN8zg$vaqDc9|dBsh8(U-`8}+Bvi1m%BJYEr z=aSA|6mexSqW>jAx<9!CcQA?K^xO!oJ6_#3Y2?c{!iQqFIXdG6CBkgQ|$J zmG}>wX?9*P(fCR2^yQ8x&C(y#4U}gZy?8iG2q}z(u?RVP+Z`7#$Z@w^j<&pnpWDmq zf&#as-B4@TE$dqic|pvp)Cy~)Gv3NJIP;H9tm)y~oK{6KHuK50>>6#Y$lcOfZJZBg zLakSP6BvZ_31Qs2`uTSqpxsq;Qk8R`Pajn2>8!qwpr?iRmo-v}JaRejiqmFq1uUOx z#e4kawsakNrWCS)lRdJ;w8%GEhk7l%kg=SSDt@ykl(4P0K+wT9{MExz_!Umgbrtab z90&1xWD}@0DcS^CBb~<20)`W8HIn)p8R3Ixc8~Zps}>!@GoSkW7`>Vi@eyd8iFaJ! zVo~))sk7`F8M(xF`bbrBjF?poYMk&rN=Q03TLBFEv6wJmcvSG0og%JP$=M&ZT_%8v zgB20iY+~|l%9ec9zy;e_*?3fz(|w_L^iX!3&F!jY|9m+nIJikRvW(93T8dfkW)vTi z+sUXNCT#g#y_d{4Gj-p2K`y`QTM|=Nf6=u4D}w}@<>5D49)d+erE3o>?)ewt>C=&y zYgS4bJjFnUQ9c;^#hUw~c5Uu2d$BgDX_7gY%Ta0cs_lID(pmk6y+x=Ed@GnZ1bHy# z@0A@T!@+A~x47h1o#j!jF?aFU<=)O46=av>62<7F*`;cyZYW>v*+p>-4CL7~dWGF( z1u=@mcORzYefn-neHI#}h%7fqU6~dfX^SP<`W||-kz(^E(mpLgmKh5jmZheJkTyJRQ_u6J`T{C8lt$W#3})=4 z?dyf0KS?r#ReeB_=AJ7~>(wvPsC)&ZYhi1Q0A7NBa=4X85qst@m*;IG^D(yE=R6IAg5<>^!<_`y;WaBMdV0XFWmr8oqE z%hktOzyERkL$dP=D~Z;_e*ZDJ54|*L@;ci(YrDI|Cr9{6y&}UsL=Cj>ttV(l9Agfm z+koA&XA@sHZQ_2TZwyzqfYhhoIgq_9_YP)?-t#iEQ2z;syxDt%I$>~W?%V(oW)JzDnRYlp}1TEztp zAq0rC-YY)DFoOjn=9XS>3kgAD%0-MNoswAKz`^(;d=}A%Gimfe^BUb^)tzECQi97l zdF!@^_^vAGKw%aW<7OPT`3hMiv9u)qyjESB#dl1R9rt={o*G^Ds7_N=b;-?rX7O~k z4$U{o<)@E1F!oDFy}pjB?$6V_gJN;mY}{f_n-q5wMiA_E9zcNw<(g2@?#3RK|+ro@_oGtWEfW?o_bJMP2xx`RN^Z4qH@t_?OFESe(jSsj-fIAek>HL3Q9yxM!c0@0Ga3ngy3( z4DPEW3~wo~+?E##&J59{tUy$W#F8MQP)+ez=VlMu?B`GAt z!+!R~*Yz3#wQMSaAi7<|)S`J$Q{F{a;zDBY2nLaV(UlZb&Fjl0GzXs^^^l=wsOlZXf z8NL6S1UAXHKB80XuIKE0F+YjAi}lZRh9=pi83rp?#|4L-&)r{UkV^s200ymS^N) z{D-Z?-+akud5eCo1!a?drq&JXouj#}?QadFQ79i)dLTxioc;IoJVqO~qgc1-mX?(; zKfNK@8%w=0ycb(BtnVsvxs1;%^kd0+MCMK`MyYS_><{;1MozKLGDAI?%4!k;($ zgwNv1;N%COt}zd$&7v8C(^DWy*Tm07a7Hjwh=3V7{Zq@m+*qHYcoIl>1pw_039l{L z>m}vdyJA3MjQSq{Oli@9XNC*-O&KHd_v)h(U@N1hv3Frxq_iFXpHPwc(&OF?Q^ydNllpoKj+3Z2 z8a;&+qFS$tDvT6%illhVj^A#|hnd^_i?b@w=Q%t0iF>AvU1H?6Y0ahW-Z+a7SuM@W zQ)`kJDi4njtPUaM>|FNoE@^3O_HiJ4%wr&iQWP;b$gY{3hIzQ8(T~>$obN3?i5BmZ z`}CBztoDPWyy%3XmDTxS@lKjJ_XOvNYK>PvNA79qm!smEUK6#Y*sp3rp)-431jRyM zN0~UDJ!~|Sh*ZEM79ca4qs8fOK)#E&|CC;v5B0{&~`~l-qEMXtf!TE*y9%GuQj;9(JNhpjqkpK?5Z#nIGBgZIb+p{`Pj}s5)Ag9; zBBwqi(8DQ?|Bn%thc``kQx=_O?@GlF&{4~H`xfq~mZ(e@^9+{{ z+aZK$Xk5@&4Cc=Jwi7Gq{rhQ~s0s&(LDoSmjz{nQc4&BJy!vXOC1(^UveLR+-pF~6 z5)XKqdmwd6mi!U3Qmg6LWQ~EmHZd)jeYofR3jqXkZBIJudKYlhd>#ejhp|4P^r~Mh zg~g5c80g48K5$~|wwKn@wGT45fH5bQPQ*etsB^nQnfEgGg_0bDMd0*w=lvE(B#N10 z3~LNwu>{skkiU9A^3a&||Lb7(4VUntk5b<|6P})76$0CppQlu&bWfl(6OQgN*XEr) zOOYdK`OA%Oa3<)@I3ef+kB}*Sej|m63iNY@>@NfN1WP#3o9DTR>-kEWgjW8<9=I9o zq94P{81NjR5;7fgj3-0H{VWz0?Uw(?*y__{s)NpE*x36=6jiF~ooKLx^{zk7* zLU#&j`ZK$9-HeR^&&}2a2s&)J(GGM?57!P*2@z2sY7+xv?A$Tqb^d*aZ7gmdCW4|a zOG$1ce(>CF)8#>X9R*w(*jKP8Wi+&;oPtFP2L5gcg$N~(lH>Zd-Q#zmC3n8%u3n?m zB}cG(&w?}9p!lb@&dUD2SfIOOy0}IE`C&!M#u9|^YhYEALRzh0hHZfjnG8YFIDX&4 zB`(7IFAx}J{xi7Nb>deAOhI85Blw_$4-k5~2&s^bjOI^~Kt700T|7zLoVQiFnwiau zBGPf_r{skx;wI8Fbh+F*`RFF@W^Lf!EEX|6uES$r2sQ4}H*@HeG2ieo8dY@_J z7lDz%l%PM1@2uGZQ@EV-6?rjrNao_`o)EJ=!n<85=;ghFS#{=;c1T@O6azPV#ncxK zdE5~zTL*cJ)?((-=psIiCNyRWTd?;@uBZ!DpvbmL_rX{uC_$}Nif4wi zE{5;uBpwJ^Wl9kLv?w+mCewnc9$86zPDb4ndKugp3*q#JMQz9EH{eFO;p|%K{eCtFP{@{Kkl}H6Ec-w^0w^jTt((g zTDm<$+kdm}k6%Ets7o>(+_|;_5WfXE+D2zUk6)`<3q1PTmrb2=->;;(um!=8}qj3@Sn`OXwwp|949>>Rb_7IOty^?j= z{JWqj7*@~KBiy@CsT&<`(IDmN_jq_OQvMsoj`rC|v!I{E(l6N0?5NG5cfAts20I6& ziRvmKy1n7aAt!}?^+cCec|sk&vwIp$MW2oDE3qg9#;`ymZRTdFKyQ|xaZ9trI<5R% z?K9qozD`T4lR&y!dkd}B^PWzqg4t0EOQUnJ$co#N+ATY!=Wk-vF=m^-47TT{Q?F+oLbk2&x~-?vZb=2_ai*Rsb*a87c36xcya?~({{@;fw2#qFf; z$0AyB(JoN?eVv}S9n&Zm*;|5&qjf6g3milQ?5x{XFg^~*UCGq(E9pW#k}=)CY!-`x;3Avh}>xMkzt3xgLiAql#|H7i?#f<91`lz{yIHd7#8dcm+{gC>LGkB*XsurA zWMay>ex6r)xLDh#x}I&{lW{jcJ!8;$Y4KVfSa3jU?fNk^VngkNsC7wVkTm#k-Au>H z=I*J07Q|*fufivFbc;4bBruqwUTxo;kovz|U2cm5(195i=V3RQ;Y|Bu$G|Q}rem~k zm57*B-&5+JE3S+F>HD_&8?rM+jjC#}wLxu{r1p|7Z5HkcXr8x#k-!#9z^#Cc6xt#& z8a^Vuxd7nGOnH9+yAhUCpQk8{l|N`OHB?Bkio?%% z6si5R_9t~q_C1&!)CFU^0w*OI69NXRafJX+J-4Y+9`)~4e})C0Q2f?dqTy%yPgOX>{&f$iyG_uArbnHHTWA@iwUeW#d{h!y2;(e^tI_|rx zzF&SUC#Xg&1b}))bAtnx;IM2_9(Tkx`==jUyezt;qN!?rjVC3%-K3`p>vsg>GMlbF zl-{{Ve`an0YYBus%DFK(?>-_+2|X68w%mftObu@jOupMkFil8=oAt?C$9YuQD`7f# z9>ZAde!AjZJh=7Q1mjECFXzhrz&pLd==AZCL9Pl3WRqUmZ*0owSJ=*_^!D1qeR|0u zJ>(AqE^Y`t?e`ObB_Vzcln}()x)HI3bCWW=b;`>}vu>}9ecr0bgAvooRv#MUu(QiV z!A^PSqF`u0j7@b;s4fY=f~pB9A1b`vJ@)c9Ho{o}drOObXk|H-)@^3ur2weKVZ6E{ z$hw>T!@JxFIPQxC;g_97nzwm2eYKsuzn-`u`hpo1(mj_IwNr+qa{!lNtCK{?87WS z{>0S4>GJ*2%Jim`q|7g~sWw#>|3@)2r`z-{#Pkf!L#)0`IcSpc*PeYLe+p{bN;qGW zk|ck|U+XBqXm;3zRKdGmuX1g05PR8t!6R~a?1>@pXDY3G*@Nr3Yv3)*r!}~5SfA4q zFPwVrbGEyyGWpwG#`LqBzxKTOM;ETEe$2Jb0Mh(NVI7z~*?#B9r7Po(qt0O28P4Kq zS`VCd@Gz+B(2Y_y2&ymQM#Y+nk>;W=y#F$lpBi-lEzhNO7=8k`?)~w~c z-}6tbFO>do`LQFWWrq~o-m6QgOEMilGMA9CV{0=LcG*q1a^A!8`jZu=hvl)r2B8y! zb9Pj_$fZmK3QyjflH2o+9MF0y@HnE)WM8-ymZ&X%Vz?Uq?7=;}k%kdm|B|27wWe``{X1(3KgZy+|j0)2!v_$uoK&Ov` zISqWCAi>rYUs}ra{b7#n!QbHEl{*Je-$ z>fnN9ing5~#Zi7}-uq4pt@AEt8EImAFaMi375KtAVj|OS)_tn<{KML!g>}-s8Sfs> zqQaqLBw0_cNN-1qoBce7xXYFGoR{s1D!2-nb`BBae4}}}9qA);Fk_4D*Marfw)A{3 zvMOg3x}a=ZQ2lagw=>tJ%Y93X4v~hB=C!7o|06$lZvEn|u4$75lT^-_WJnd($Gt}z zkGdbyykF4KA>t%d1Y$CMZ=~4ENeEA0CVDj{q6s+!pB!PWKN4XMJ&C$$`+2btd*PPZ z_Pu+hUfr2ELp^z|oM}df`95b&-9^B$v)>KAy{4K-7KN_pyBz;zZ_>Ezg)-f%LYD6 zqOXDuk`ms1P0@<|vFqKX02KBYjf(%guR@P#rP=cq$r^skoi+mHy}{p(#zOMqK9`l zf5+!kS9-k+LP@2$CYw|^Xb?1<5h`4K!EY4r$(4uJ0!&@aT(h=ZPT7L1Yhd({A&ks8u%k);OhL0gVftdFSYJiHR5m**{JY`>PXZKGnb?WFJBFHo znNBE+*KArFBm-y}yqC0Hz8o3>MU{v9W5sIG7_V-Ccf{O2)~z^6OPOY#0Q&8y$bc%l z+Lipy?oSq(zja;(vr%4PJ0_w2hqd~1d+oFK-yx-4MOWpmJM!6vOIcF~O4WjhIFrL# zDp%(dglHQl6H>t9eN)j(eCUx+h$U3+xl5Rz{o9@a9xM?r6S}D<65swBsW}y3X{fj|+ zE$wTaed-VL7ErD$84}CNyXT-ho6wL~f*OT?C!KSnaxHl`eil5j0-xlYl`q7)#WNM` z!A45{PFOVO%PlFfaW)eOlCL+iE_6J~m7^CG%#SLNXl?F!d)Cz{UfVnIdkNKA;P7*L zZp~S4dc86(aDgu1@&#rxwwKYtV+)?P*m-|hd*`fkGWz?Z=ltTFlX~fz#dNcAOGkj1 zdrdFF!c2Rm&S#SH;mc3n=~vnXPi~Zk{0GF`;Z`Q6X-hq`sQ+{zA5K~=c_HJTH_+W7 z{|k=ZA^(QhiVl9y1|OnP0Khw4{k5Xu-J08KZ6ZJH`K?pkWR?uoQt~bnbdE3Px;c^G z&#j{1nrDQ2Vge>H?~>w@3^r&+%GSD^<_x{BuY9amH=Ba8IGNwCzgeLVOIH0}Us|v9 zY~QWrJXe5$mO)l* zF6S`cF}`lp+yX^4Xem2fusaDSKbO9&nOw9Me3?6&1x4i?l^4|Yc>l(4)$TisRG2-C zCJ4^N>vSkkU7j1cwf^pQ@&xZcdg)<`F9{)>4j(M35Qov;ir$DlHU9OBJ0!85kZ0+_ zwP!EnIb%d>N?@eTl1e~CDRq~=A0N*aD+20=5-zF+@R@dc7r~QrYrdLLvw_%lOV{K z@&Y~~v3&m+2d2}vyKTYQx4SE= z@IZieh%Fiq)jSKK{fc&L$4n<5*-RC-m5#$Hqba$0E0T^n^x@Ba;PZx5@*Mk%W7y zcCEK}w}jt_CLi+%G8vM=S2z4xj_MN6EqxlZ;8q6O5A&c1VON_==Vn3KA1N});O*^# zloX70M)mli0x}eMIC*WUS#&ua-+F##6a*#6IxZeGFyOR}X_8|_uDctAr5hq>?&}B6 zRkfh^Q+csVFn@GWLHGRM8}mqbGAtfsxtSxub1EtT+Z$Zja+tTR^6X6=>9J@V^03)Y zJV#o_4pmhq7wnwXT8W?On9K^Jr9D(pKbwB}z)MG}l2tGB`xD0Y&+}L`W>1{K=)xlD zx!q}B^7K2#$O@|y>z;{Ln>p}~_TN3tB6x)_YQRKsSn}@dY|+c6pn6!+JxU~rM{Db^ zAb0g6$mIu2msD)5qA0iA%pP`5t9uyP%`S2^cfnIKG1vjNHSSga6G`Yy!LfEKqPl(} zr$1-NdT)(sDxARVwrKpTM-2pIRb)T9gJ57ik9x>!@e?_m-2@gn?E~e?rO&ZvV+CM3 zQWiom@|2#G*dIl29}|mz^RaP+>Uv$*C-S3Ts#v|d-a#*B+x`2<;1;rN;eq=9m^<%i zw*UCwM-^?Yw$z^07NutGSXHZPRa>+KwW{_`2%=VpS+hn^Ep4g2#VBp46|2;WEp|c> zLbxB_-@X6cbN{}7aZWfV$!ERa&)4Jme5Ud9gse=dC}P>4qUW@0u$5&k?EKfN$&k)6+lhT9-N8&M_{f7g^#;3CdWo$giT$S&H z8wo5naF1U_sv~oa-uyZG&%qGYKKk3}sk(7^V1;{?k>| zp&Ns`od-HIl7fz1V}=Uxv3MpU75^EC0(}2sqJ4tWtrk&SR@in!NpH{2x7#zOdpUYD z|HN~x1&!&JoU>#i5ZttVqTq!TQg_zQd2JgMkM2&h zy3Z*Qqh8o2KQ6vp4ht)h?~LN2$P~8n1-*o`Ft>*GIh*YMadrgKfxaHBRN_U@MI}MA zU-seM`dL7>x9Q5~e!AG6%uF>b!b456VP(FW#1|a5onxMh1Z3>Duu{x>Pl=!+8y1J7 zr}>pF_O7bwWuTXV%{60;A%4J2IjU47*U)TKRxQej0sY7ip&T-?F%WTjRk1jeREw-u-XFKI+PudH~KW@#fdV9tL-xY#egHjDhTk#td%L>IVvTiNWixk(CP zB!TPL^P@|QkRSJ2*g9F4R5iq`IF}?fsJPxZ$n?)Qc0Ej+`;u<;VSmzj=3vn%IQ}dZ z8TjQ}I{3gsZqn<|Z}u3z_Jn>X@8|=tSp%s~>ohI*4oP`N_bkR~L2JEF{`Qm1fp5kp zPWjIr)E=)>SGU~5FSN%tDs&!M)H1gPP1ZAqmL>hXmgn8mr|<eN>Q-p$MQ{j zJp#Arf5oZ>@^8YnH%2)#pa#wOC)l8#zU|G%^)ha@cLTcJA(AGOYQHvFSkD^LMb^(~ zL9!2CHLx>~CdA}lT@4)9N9Ppsy9Ji;X^?Mrv0w!7Ea(OSZSxC4-_NR|W0!zn*ti7R z8DEn1U;+@}CX%A0g(u!A^P4_Wo(Vq4kC|VRN^eRt@>{eFA~206%eSbG1d~k` zm;m2ioVE1TqP}}A)JT5N!6iK>AYML%xr|V`Iw%79@(7}y={s>8X|!tYTl#|+ew|cd zxvE_lbR8o*A&K*MjfWSoNdngT^y- zWG*^F9PCmT;!iG6l7WWgb=l-lmwP=ej+W>q$P|4_JL zgQa40?}`AP)*RMM4QWwYf1b6rjvzc)=vY=OqOpRXj2HbEBxsld#A`1TEsLJz?y^k3 zg235{KkBf0XNhyPC{iK5l8@#>-ya|Ybnpj%VnXUs{o%9XGEV-@eN!*^@!GoNi|n@4 z7SR2Cl}eP_$v^vTl7|YO%lA?}HW;H?ArD8#BCDP{Wx0%Uyl4J)^r8!}VkLT@w$Zyl zOwE_cN+YqE6mjl{{ySc&#+Sw~bESJv!8$kdnQgin|Db7^Aw8};aSD(g($<>>*vTPo zp8M?@wF$l(ESpw4_4xZ;L9?>F*mD+P(|h*$)80q!tE&vXA~#iDkZ?eav0awyXhWrD zu$1cYY(xbSrX)r)%Q#RzaEahD>OSTqJrg?VP9>1ZKfbro$2sDH@kjUcCtm%sn383` zJl+9%1Yv0ZSd`1@sT#KZ8+X=W$@R{ma8)J$T6ebj3Xt9W#+q-*dh22m*=uu*b1K_+ z)G|L0aqZpaa+b$C<-lo|^y*m@y-q81v3BYf^khR+Aljs<1s^zfGn z3!mM3)hG^+MS^GIrkpX&3&HtnTzxE+XBp1q4?Reqp+8hZP~%(Jon^weHeC(eKFiOoYEj^ZsY$1 z!3kLs70!`as~w-G67zL8WbPH{3o6P@J0^Y@w8^Rl)HR8Ou(`Y1Y2W6xt0br{x{fb1 z<<=1A=?#=p|ZGk~1VXLzI9AnOv zk2QjE)H^D=kPN_Y%&DGGJp)FJT228uSoNLjCCs~Gq37PZH+*~Sa<34g$@yCb$YcwM z>567dP6)!uEAo&wgAB@qGJJYf?j;ooA5YQMj$Js42ccv}Hm*fY0h zH{|r4YbBjDxxCd@DGhTs>u#qB=!oTAvee)lQ(pK4!6;~cY)K7{?PeR7nwUFBO)kS; z85amb3hq2V6DH^io-6iLs#9P)N5h9TF-H7F+u(d1=HWtig>n zT%R^Me9B{eb6!??O-9~fDt3=#d3Z5|+()nTu|GBSHe?#eOqrX@KW=Ph<@DP5Y`TlO zdDgW-81L+^(bTNN6&SMCc$SVYv3Im_gDxBh!&O&LA7%#3_}A?M!vpBKt$ND-Zf})w zjHb0P<_=csDL98W0bMR-MSek7f?*~9)M7H4aX4&PE#&Tqa7mZA-*G@NlGX_rJ-BpP zcH&+~z%hyz%nSBBOUWnHu6`S&6L^%f=>AT>@WSP9>JjO9j)Z$)6X)!JXXIm|@i0($ z!J2njgk>w!bL)^A&OS~b>IeH?*gg9WcL009$mtEJonZ0;8?u5M4ygyOAI*ieYGSAS(txuIQ-gi!x7T82OE=}TF z;-yyad?X!ot}|pO3%Yg{u2O)Q`7wyEVUTcL4|lx}b~f?(xARa9HP{zuj?R*D7cUAb^M`%PXV1gGjR?eF_19f`K?xjEtK-v6pI(q+chRPj^35t4r4taKnl z5yr21M7RDfVP3+YuL;8gEHOGt+#+_$$ghk$h1Uk?BoJKdS~jcKDH2$1`@PnhmmZO) zh}++&7LPMsOPLzp7Hy>tr8|q5{Lls zmx8G*_@_TYyOKUqBsX7N-vEuEuP;W;xHrr2BI37-b51Ud%vn*9PruS=w&U+}%~qCp zAu)uMQzxs4k|EQg(&B82jE89lZnwGCSqH@V52NaU-i*b*M|9;}Sv!GK-oZgPdc5lx zcFRl8mXlx2O`^L$eKG;keWE=~o-*VH-_v8eEA*7rX5&?r_bOwGk(hD%j1%Pa(7Ozu9f{>ZXHSMO1Cy2UC(uUPf_K(I8o|LLs!3 zA|D1x5)Z2!v zrMh74zw~4n=zB*AhEx{RFrnRl7fQ6pTlU8Tta?L;(GSeJKNYuQF8&7H8n_A=3I z8Q0|x;jpjhaO$GE0hT2GG?{HWPSoF#NKcP&UWpsCS&zJr4N_$dPyvtJ(xK+qX`Rs+ z514dgS8>Smt5i2+L$3gFWwFezvai8<=-u;JtV0eE*41jGQ<&XDjm*7kf<*tX=1+wNF*IDi!N_3{dBFJ zxfQJw26gQcw*|dKW-=~jSM_p&P5F_@+Iva#AjU+yY8&Xu;e{-jH=|#81nd`nn~ZV^ zn0Gf~SlH%z7`i!H&2~6Imyn{|i~<|)8Qc~pU6R4nVhE%fF#~AnvZBoA(4si~X(uMm z?Zddve0vu5pyx^Ykv5}6YSg6w#tTOX`W~XW%XHri%JSle9#LHY-9a~`CbWioUXkD3NK;=hgP^xRb43jSOd%&Y zM6hR+w{Ch=l_dWbddY#aC~+Cs267`GV)eOMQLoNjoCTZ&7$)qT_h2#lX9q*dS&3D} z=%1YIhRSbuC452I>?VaaqvtWc`ZR=%Mp!cZ&@r86cBge66^VAx(o%q(EOUFl3vh!k zIZq6vjhToihKuB>Z8WaGh^X!TV4!>HeP2X*VKrle>x7W(fgbKc{*e3V{7#PqKk2Wo zQ~P8ZE~+!TsI<4PPhK>2NO7yadLckX z2g^Wxz0NVpQ6+|F!q@s`w2O+8`chy*o?B7QLS2rceZ|mh>I(=g{T5~T^J+$c%7Z5y zMUkBkj6SLr(+RXJuN8ODi%LKLXMP%`y18~5)RjNScyI$h!kPVI<8RcvpSqjU1f%h3 z-B9cTucXjNhmv#F0aNBsyS|;b1JIy-(L1^LHD##!?u)gv>d}llRI!~zt~zHw5x1u_c9Lf6u(uD z+(?7KzkwdN36k+}^)0<4~{ zA>WM^ubhrry5_`V>+#6l$FyS0nF>LpoXViLV9@2!TIuoot17B(#SU?kh;fF0DM`OM zWiW1D+2>JsWN^FI`%W~-Em8mYrf}FX=Jah#7k8FNX1sZg?1@Wbdunb`&rYEP2wk1y zeb~gE;@H3vdJG+j$!DSeS`nT4rQ;nAyKE3SKMVf>W&QK3AGYAkgJ4P6&8>19&rX!X zB;VvSMrv}aVI6~>Dr-^N`(-So!G7#hg$%k%9}^ zCw5YsjahBQ3MNi=4Z@vz|LDDxZCfeSBNpXg3D)a0zkP2dyc*?nemgCE`S)}gD(FW1 zIT@f@+qiB_H}`Vlt-7edmFO!SL<#@zrNP6$RGS9-nJ8qbV^)@qUT#`NeKlTb`2E1= zDA5#_j$03xb_>Yp`gF|a?1?3wJMOXCU$qq% zVi+!PUctl`SFY2in0}tHf&EUkXYhf=YIdbjJ%2ISFDkVM&W^hc+?BwOV{@pPeO^J; zqDDF1MlGjL%4#Fz+`pdH>*B8*NYxsG-p10;&fcVUxMXqJqa(QT`LYxUTTR{3MmZQJ zadvHRRldeUfA##7;_7Lzbb>i@FoGCQCA(-7ioOzLPVl(h&nFLF;zVys(g?(LY4n1r zZ-ZzCs3H|K$Ngbg)iTevt3ofW(<06vl;P31&qc1>OG;+y#ue23OOXYAy&*Z=F8ek3ki2LA_iV5gOio&B0%Tb5We2~4;*B!CAgjmqv;z^`^ z!utND)X;PKZ4pyq{?Cq*XcNb>E2yr1m~`JFyh9#B^u(!CxArEU@GIq+Eo!W>^KE}h zQX-1sdUeExZtqxiYom5{dhC5gI$7R$VdQpJM{ar15!UFKdHJ%0ghky87k|8^W#x;b z>GsKmg;713?GI^84*jd%Sc#C?%%saI3kW$8rk`<|hR<&cM~g39)cDSWUmb>3iT(P) zlfZ$HpD?S{Ks3IWXe?K;G1Bu#_IkpfGBe{PRJN{Sy~4O7;@-xchq3O6&lc~#HSgv6 z)KNV=JyW9PPCLZ{GmQoh<1KIgd@X?5m3_?)YY$+<+YwF?^bUp$#(V@D;OHW4|WsK{4H$DR`^&|^6jEmku zXmX;i+&(zOtLs>>>M0+W_xFDw-GO3jxFvmG2ckmN9cKlva7aE_DNx0^lfUGNgB>|n zxQOp|QY)#16&R>YXn%o<0;Y>{f?ma%S-i6of$$s}w+G&<0e^Gods|Mp>$hhx!?`i9 zeP>LyBiN}Wayk3{U5JhjbRBqdGPbZcc-ich=J@>&8+&$se2?>s?c_DP82%|%vp`zTA9Yl!Nos{!gsS8c%(@#>>cpXYr`s1RH2D6_2 zK-tcNxIe;M1Tzkc9I!U}S2E*;pgwwyXoUlRjCG*)jC&=t`%kl#_Ff9(1MZZ$92xn~ zh|820;^1R)cZX4Ws?wGHbu58trGy$80v9@3y!R%%=%EU4h_d#g#6$*zl9%3SaPobR zLT8vZOp8uZK~X+;M>6~uM*Zl<#BGfkIY>~Ofton8Iv>GL3AMfhD30MT zKc&joL&&_lAOFT$0%I)Q_U6U%cEuYi(e7~xBR;w>Jp(4<_fmZ_M6RKG+xe?|^Gv+n zXzsP#c+h@P7i9D%vB)uEPkyDb&fO!-1iCWfOnQ!Z>jk>}nL$HuoKq<8D^EeMR$=yf zTU>C!yR97Gr$t6hTS`tyArur{p(9_764zii{&?fXvG;jvk3?%JYCChL4e3ZD&Yge1 zs?7Z2$6O=Yi$2FD=q72}3v6gKQyW5(z0;=HmpIHCxF)DZff|w7h(IBSTXYZG57Zh| zLtbHm0no0XH!H6fe@mob%HZJ9kUS?xz-CQ_tJ+3c7^7rjq6Fdh?D$2qMJBlnZbXiP zc37kw?A~H+lB|l4om{kT?j?{+3$T@NkA>@!y3)L4%f67KdSn@8H5Q%x*@PM8tfbX?#qffuL>y7&#v+U+P)0A!Yz7ND188k3n4= z;!~sE@Zk@?G-n7A#j+C{|LEi-|I%2`b4*V-E;KDrW0K^71#OKxFI;08;?Mrhx-T;} zdZR(6)d87(jN$!D7nv!3zB)g2T&;(h>BmptQCgsjgh;TYauy0?%m194WIRA zMgRu)?X5OlzR>#T=Bp|RiaC~TKgL)FrPtCb^D(>hN~XCHSLI``U&sw5>q8G>DL?7( z2}%`P?kON2nMKOW3AzoHqGxny@P~AO%uA8)Mb)m>>g?*bx?c!y-}9MSfQ$-mCE1gp z)COVrmZMVggV+bB0eAcJVg+Z%KZ!JSEG_BXc481I+9w`u)p%|!Z=k|uqLbM_p*myk z-8)H3JRM;%zONVmY5#T%P^Tr2xMZ$I{j!4I%w;C{PicDO#FvAcghwj@851eJeHqVJ z+ERn>eO}@#@Ud#qefu)3OOsC9gT-;}X1wgfu|F6tYh35F|E0GgbgXjgDT!*kzgV>1 zntq5Cz#BO;c>CqAfiGw{t! zkNBX{&G$BV6y@on(xH3k5@QhhAp9Ek{GqVjxW3G>NrXMOD!)O4xdM{y~-1eg78pF1zzD6eF{nto$^H}!Bo+x$sa`g>{y z>Ox~*2ZCe4E5q{#n9VennbB~RiEwJq@_XS}wNJ$i}Q06JvHS}g3cJzzTjDCUI zp$H_nz}B5){*85TW>RBkBI8AZqwu>x28}8Y z`_F`%A`9$GYsl@L%eAoLSQJxH_Tu8fq@4d+50LJ_(jfGO@s}-C<%H(RQ`(mkPQ^c( zRKqB+?hzN=*gzULle|#&1;puc1M2Zr*d4lrSjoFD0a~tfTpcdO2cN(4P7Na+2<3V( z-Q{}^1AT&iqst2R)NjIcS?pyc+5XE0ZSEEm^~HRD5ovf{1>YUWl|a+IoVbyqkSzx6 zl?+=JP9M?-Zill+Z?io8wGa@N8Oh)FIO#~{mU}mqn2q@`I&Yd{hXLbS>>=za2zfUhOTlCF88!zmn%)DL!w?($+cAP1Izfn(ViG z-5(S%t3P?>Fsrrvm&517QdyshW0rD~)HE8auV29n{_xtAK@RN!) z>v7Sz!SglzDsugxcBbVhs&jO?Rmu2_a=2@h$56v=XKAsCyOjES4Bqn1f*d4Y%D4jJ z@2(Cpw)h(IW2C@XHFb3z(tv&ZxH;8TtV$XmXIUHDP$WM|%G%riJ^6@MZe2WN)rCPd zb?}xkqo?)uz3QHtZ@p$N1x2v+INhbHCsAl|*?p_J$+b!R1eL2P?B3<~gjYGa#6?xEw=yN?SEk)oJu*t&0hxlSLQ zvikgoLtBK$UlA)*o!wQ33tJAj{Eer5r#Eq#OIabecm9p3p7vmtQWtMhmj_pI1K-XY z;Mq|!6sgUc_xTlGsepBoBu4Rh0Y)i~WeslEDRevmpdaj8y12TevD$N$4^BNEYmT={ zYk%;faX2-3aUXp%_d5UsHJjOB>mQ`va`?ZpLH`eI=>Myi$kXAK;ncK+oP{}dCF={^ z6h5+I>F#r))%38TY)Gcsu%DSRDXaF0i(5OfJ=k};#pXXE>O=`R!(x-%xV!7+Z8z3i z}%XdtMs)r+%YRd4fV^#OvY1uEW-3_`PQ|6_+f?I~8#Aaky$eu1Y z1^ir39b6o`xnfY|X&?J-{nV!w%#ZP}nJ#Uumfl-bCAmkBa?m}L7C`2@7mg3n;Bs^M z!j3C;&-x&90{@8~OqVwO2bxj#M}$QRQ06v{d%N9Yl0_<8^{RKI@aOC}L^fFxR0Ny1 z(XOk2nS|Tmt=azRg@x&o7->tlq~Ald-q~h920(6wP&%CIqXv z)rFKHl^4!gLH7pYC-DP#+I+%`v77+IbR60JHWQ!BjXvof9_hlRoeU%Or4f!!px`|V zXnO~GcUPT1!m*R;?D6x+nW`W ze5zdaP9YP6&U;b1BB$tNCrT-EhR+0uW4p;!+%fjc)QYq2=~nYJCw0l)BXh7c(o_x9 zhmeLrkY=S*okvdGrE`wKp_J(@b70$Q(@-?ct;1l9ZzdTbZo8K%zWnWXw-w9DGp4N+U;Wn2nm7i{$qkNr!XH=MH^c!Zf%MacmGZda({ z(R$~Ewk2iqZo~nwkiVsR_-n!CU~f#!FTDsSc4A$6BD z6%yE}!rfO8uPD=}w4UvaFAGz;3&@76F5xh?3mr{|x3Y?Wes#qk)$JKgp<$9W{~pbS z+wbZ)2-O(NM^^PJ8e|6VHw>Q@-tYhOCrRB1-mEYm5j^$*#OXpJMn!Tnr)-tvCfk=8 zued?*1BU0(cFt}=J1^_eBHb=8y6}y}e!qV+OzTWz=dJ=FN%yPefYg|IHReC>dYHGi z>XboJ=rm!Sc&1Rm9Sv^6V~=P-;~`_yLjYLAgHB5VXsXTOI|?&A{w0k=-%Z1yPL8f^ z1#$P5)#V&7b6Q<791O76%C-+H$Z+rXNHp9)DRuIMJdFL)ON9IOrAH$b?~O?;C`ii(J4{GtsCj5)6hf~Nq*S_7^ zNx5!jaHr)lc`-Qw;B>6HhzzEIyOARK5d*jR$9tA2j;{TN-2No+VCa)Jz*8- zO3sS54mQlZJ_d6uJsQ;IW4e4W|6cMw>4y!+4+tI`AL1aB zvr@lec;I?LqUm%Zz~iuRcOhX$_iIlE2l!AP#RNPGA2v#jbt29=GvtyZUxOlI_}~KQ z{wm_0f(U=08qzI5s)6BEN#~9KaCkztuU?91C2gtvT#qMt zHEsnn@uG4adr_Cfsx#&(i(H1(Y*Y6LB-G|vFJJb-Wm*dc^jChd#KH3 zabqG`_E#c^@6FFpAhUMJfb+5h`1D><;N4dvovX3K#>W56qfWpXKN*ZO0y1sC96xQv zL8FmjCk-4n`V3(=#_n9CU;xSs9se`*loB}n;r$&>`(U8JQdR`62EGguWd0Y(>9@Bi zYHx9~Dl9YnU-Y{B<-Kk3kQ~hd6X}TWC+BoBvGt-dmqDn=N{N0H(3U3JPQNo^W_Cm(m-wSQ=S3^br|3hCXvx6Dl?aI$`SY(C)+C@&mkGSJlOr$|?g z3wfLa%p$VOH&0RbcT;0brU6FALRl8$yv+pj$eOa5^#X{Bby{ET_%0CVLR9uGO!(n7 zrt^2*FM!-MTA@O?ZbU-N6-72|3hwXj?Dt$@rhg|riCQnPdr@?MIhG*uKb<@s#OX1u zUO@n_A+&3(CXcmU0PP*vhro{qV^-^dIm`Dj495anmd6#0NBI^l6>>2yRnFNB{NX@5 z;F$KlyHuCyu$9p!S9Voe9!y4cHD z0Z6`3f}+fw%%6$m$-G{oMvjTFF-HfDTMAE(YgzLxFrzLG2o+skyY`(vbx#h^B0vcl zO?p;MTfpxuEap>}BW^9sYQolkD6}uoc)2(It);4uY#gy}b7e{4^_$iF`5aLQg>b-U zEsR)g;ZxF`G&7P-yOq$V{~oKu8XrKc6x1j3#7VrEUQSFx`TQb_A>XkwZlh}WS6GH( zw+qV(sC+pqxVrA|DdJP`S}ftCN))R3JNdRpk*Y-*q+OLNC~eT zHd3q6UBc88;NQ4GcG(;pNcy>+TO#zHf6{n8^`W+f8#j(Tn}0vGtB2M_n)3t2E4y7C z%yi|iTBzUWFLNf_{1(_Q;Z0#IRXe9)XhJ)Xaye1zy2-7PnvD-VwCbhx8u)!BOm+a<$zyPyC z1nyoBv+99uw$rbe`Ekw&e$^Y~;`en|ZE{M%3)REzvZ8?oq4@zztx0l!HXTt^L4}+g zf*YPh#q1Bia))$DdzzHGX&-|~QT9yFpYe4i>qztaQ=#iWr1`fRo+5gJ)=StmOiLF> z04@z*EzEfbVu-2jw3rAy{hY5QwN{d$|XQT{aDp&&f50WBLfUDxZ+Mu~j*cF&E3&n0; zpuz6mtJoNvE<>vtVgl${yM{L0@s)lU#lcA%*=eBk2Z-xA*5VMd{Q=a^zc0FkICo6D zOy;bOH=UHC5EcTjoO{%o96f>SbE*A(P}00S;r)6G$ZOUMBjeSzn|was2u~+Cdgd{_ z%yg|)>UeL~CMIYT}F*+d`%g9&tC4UHy3{) zY>vKs*sTsBb46M zX!o7-KNtUQ?E8q&&rmC{W0k{zgPKQQ+uP2+|E6$zREoQsBW6a7UN3x2CM2=f#{y^k zNr{$w4QkG`H>ioH%MsaDnVLV8!?BjK#;Rspb9XfA^;(2^m{ESOa@LvFC{Du#q zi@F6ANVAb%?&%xrh=nCB#Zx_AtnNz3@jh%bfN1h53G1rz%cTEI-n)zEA)=|cu$8~r zEy3OLlf!DWZVWy>ZVwR_vsW(rBYxG7VrG3loPPZ5>h5Pm0IK5t+k*tHT}v?~>^!g%||?03Pq>AD4LLokoe+Z$MgpR^jFhnFbZD+k<(3ttkMg;5KK!oKRd@u+<6 zlpOzM%kjLhlrus?3c7=g-J6YwzJH-xjA6IO_1Xk&KL;-abY^)RpQ8XF7YZ~MAHWj7 zXl!?OE&@KIIqg+E=VzratHbv}*D-&&ic9%s(Zx(Rme}$7F6mUxDSO0`WMd{IfmUY9 zCle9AS&Nk*_h~2`p6}9sNawToYWvy6u+2oqgOj68{uoUsPDKOSCo$&{X<0SPuUvI9 zjP))HB)*YISc_bU=Z_F}H+hPJfc#+EZzBYmfi66CS}HdW^dI%yWg!I(+EQ;A44na5COW=|d}< zZw?hV;oNSL3-U!n>J9~ANx3@DZy(#hN4eP0UHRDtD_qFGeDJw6xn$Ca<_S|6KH|HipfwWpw&Q zu!^Vqlb+fu3~%hqQOg=TD)wdS?Ewr%u!QjyZ>CstHIa3t7_M0J`pa$9S5f6@bcx&N zo}%J4^ip-ZN`C*vq&cd^w`S@bPOQjzRCm#wVvNHp5)fe(Mj|{Ec>+IaNL~q`IhVq%1Q_G>vS^x z{d4AR=Rga8g@A;!yxK+mYbF=V1DWE$d{}9_(LNck!9an+RTr8ekO(5(iXcLD>aaGT zb#fIcPa)5Ckj{;C&rv@El?{@x3Gln5MX?Wd5Nn8Wj0{^J!UPHU<{n}05hZzS2dS7( zOY4s-rO_`LjP}3Jtjm{N3>jXYa4q50xMD&7@pN|jf;sVex|}nX$7(LMO4=vKD(Fe~ zm<{!KQDn>*?IKZ9&5+pXxO-{+AxaT-=@tyi(~%>T@4N~wmg$IDeK<=2G@yC z(Jb4didu3klaB*E+EmHW_d+Ax!tX8Uez$b}JulW)DSD03Ufd%#xk*3ezSG!GEWrr7 zZz>vg@+Sd+!#z#;l9I^4H|NHcu{=k*--0xbR;~#ZBa9$v=-O^}0A;h~l(Y5`t$J)( zq!y)SnazR~9P_lsKdVr|cswRuZx zX6T-_r{7CLuc^9vKd9;%UgX@HwNp8M3WbDHyOrvHGB-zs+G_|~iQX64z=b^P%NF-P zr;8rnVCS?1Kwj$0(B>#=mOhO5wR7a|riE@5%*3&rt~*R*$I*zw2+Ev1XE8E1rs^wb zQ=&9Bp}>zeL((wS^$R2^xB)9}UNP}9{AQ@qK^{1i>pi(EQT~0&TEwxE65MD+{-@8b zP`b27NE-{E89r7l|Ibdz;Si)MMCSXCv$9kliKx$SMj=_yMPl|{7`ZHg!Mj}cVqK9`||8eU6YRtuKXzTf$ zFY#Hv6TdPXZ#g=-c^yL?!Bl_2m^fRCYVTI{fSi4rZ$qBOl-HFHSZJlO*zudDR_!6& zcaO!sfzFSxcpa%Gsu|?j1oru(gLJ13jG{_(L zf=_w?u>qXZuS0RDva^lm#qj_BCZOy8c%JW`n9w->XMR3=qAgIJ8Jqu8sh@nm%lH4! zOAFHeVH)s>3jEp8CTUH(HpB$~_pZObu2g$-H79g!_4H=9-zp$u~pGk+MsYjH}#-paOF-jQQ-rodlDJc+Nm)Al*bp6(< zeAGz#??=-q=5q^qXsp@0;nZLy?78_zoP9i4c{*)uI}Wgw^eK#k88dv^EHF=jLwyxc zF{y6R_g1y3vu1!a&5Y&!%WOT7N;rLBh+ITz0`=C77I(D=l@&KcdKT7EABy9(MMDY%!K7Kb^55wa-&g(t zan07wCGV}!GmGvMqMdP=FIz5!90MO0zNw(LN_{JJa(*-nt5e#D*ZDUagA0tpRAdIz zzA&}Y#rlU>eHA`@GKK)Re54H^k1vYtOv;|guPN?WN#DWJD)qH*{1nrqLnr=IK;a)Y zU-6PZ^f>G+54be^3MBZX?3oKfd5qsZMoE>VtjabGUb_etfEa$48>f?21B48(Kd z`|)W2;dkCXjV1N#rQ3` z@X9RC?oJe!eFnbMyK7_n-4W(YzZ*B7P8K%d8)bh~&fjj14=LFXk|IZ;{|*;z0)96I za`o=KK_It6v@d3w^biQtN!mgYD0!LByt%N@ucStAwIY5z{%v*DM%ijx#ip2%=6`-!hIY(;;ymf_K! z9Wbl`7!6Hw<2Eg~wmMse^NEMRdKd~KhrvhTa``J~gXaFI1#jkfpu8Q@-X^&K(pk5! zO8LyOTF754p!IOTCjq?`ozV*HnHAO+1(L*mX-}@8`M0b6Djvo_>Am)Z*ED0}Poy&~z|&bnf6}a$ zs^A~uvO@x%!3TeLK1+6&VS{IdZ5us-X4n#(T4ps>e_ zqz~^UjV2d&(AVHS_LDwUcYwEH#Yq{#Uh4TVFm1eG5xTlGtj!L|;Hy7vNle(2VMZv1 zScc{74S#9qpAMXMFEMi=2j)+{#Avso&_h#^<=dETzNXEju^vYkVAZ)v`-X^7 zwIS~go374M$2|V;z6k*=+&cL=DU5SKcTDQj=PrLM819e5Fu-Wb1puRY?K?NZnqGzx zYfTeVhFOyzCe=;`H32(6m%mmxK(SWR$w^tR1>AwiPx->x87*gnjYmft4S~wQs(%Pr zxTK^`jMx!szQq-O$_e-$pWfq7QW-#2MkhctNeLmbYA>BJ!Da4CH# zKj!%6R{FHU7dLR5?Z!Xu$7mI64|8?Grxf1lA{P~SrhmLN_(pe-M|4`g=j%$xbSrtJ z=}8_n(*v6@%9D*oj5_{!chx!fd|RgM7&oN{j14GuAHo1CFN2?p?)XCG_Az-~av10) zNh>QbZ*%Rw}^wkSq7OT#JsjnuWn}>c8tYY?M`P!yqEiS?v5YEsb{B={!vXZj(aFd^q`pi@nepQN#X?GEY=0qKAb z&&&-$8x^`lixxeg^Qg) zxX#WgepIjApAMWk4@CivmQT4r5Qg!;jeLuj% z3Gya}dqD+?vIMQw-7E;(l?-~h!TTR~Pmb_t;>5@p4jFt4h-QCI)$8_v4|b3b(rKm= z9sAMDTRW}R3~Z4Hu|>JDmm~w`ijU4@kRZU!{6cm2sd*OkRv*b&{q(Yc1yG*Tb*zY? zG?S)y7YHOkB)taWZ?k%}{blGq;F`t;5##*Ot3^e@JH8rChpM=es$oE`Vk6S0(6z16 zS!A8Y$-L<_)@UwMl5~ib_jLHpGwdnNY<-0adGg=hWrTl^zT2p`?TzWO;Y+TV)k35i zqSt@SI_tl|wypeET|RGzO#eblt&7qg2pTQY!9s$QmajgdJc3D4N;Ui1zWKNbVLv;l z`CnWXDBI5`6EGvh$PCAvO|PbN1Anoe0s)pRI?uCMx6b&(ce`&o z67-nWcWoP$xZC8+c_+&`Cq;p_m`i>Gl@UX^Cr8Hl8o11mQmNa>B8Re;wYBPh*WX7; zt4rduVO~&a4kK*AP4?$9mU9QV&ZMV(h<&Cytq}O!A>jl0 zho{5pIK!*ue)wkJg}uFntJ2@=@1YQq#Gp!q!gqZj1Ogk zz{n92qZ!iDt-jLgkpHRDW453 zMRyQv6hKymRPQ8;wg24`V0XT=wV8WGyN&f}h1CcV-``pu{k&vneOsPAm6hnvo_q#5 z9Bp|ibK=)Zy-a`t%w43XW4t6LxCLT-c{Big(lhR)5Kw3a?bWInkoA8!Ip2!x%OotG zYbnqLa}nI6Nj*^%K47O?y<71=pj}58pk1-vCddUitWQ)t)XCUodn;N0X8NxPPXe`t zO+gn%7QLyja%3Y9V4;&-t-D;ED??7f+E}T`t0PF>_+0A-QH+ojZ$+&mlODle=v`Ag zC*gUbV6IVxtA8G*of8fvFg7ww7dbbD!Gu%K9~R!$eaoJRqOPF|I_;3|!*9o72A3uP zGVOq(9wZRaJ_7Xm34pYC$j;y=aE1eH0Y35q^-_AHzbA1xs3RRTK*jwFs&=*lt$ zWZ)X|pc~^|2-hTH`9*ukCiF&kRGxTDngM*ErqzVs{|4nOu zk!80F_ZX$=3=M*}cL$tSjrQDl8xcs>R?l$xk>(IQ_0M!WXFbqf4>6J@FoSd=ymt23 ziPcST14BpKUMfQg72^N<`i5F5_Kt1TTw+V8_C=-`1OxM!{;>&`)Ln zb;9PkRc5SBcvQ6?KX;Ov23c1a1*s$T#MsAbS))E`-y$KH1)OXXMnan9(M=fZBnQr; zv{#M`p{cBlwfC<=d}@LA4QT&gp8w9J))IQi-uJyW#Ijsyg?`4IyVaey6j(UQ|1fFG zWTELv!IyOdgmaBJw3G9*)_ez1c(JpF(YCi&AMc$b*&A zLT;(rybAyTcx@wF08(Kk{!cS#-^CQ#bIM|hu3z+?eVkm{(lF;g#1a~qT!TCkYq(|o z02%#BX{EY{*hVVh=H%w8!(r_bF28n9lJ4KI@{2jf7?EIS5P;vheM=^4B{nA;)*w>(6{oDH1Y_ppkVIYxM4{9 zfGwfz>Z=I*)$Ka`@c8 zRGs$QSS^Z6bR_q%^|>hAe3c7=0TEOrBE^C~*ry76eU*cG=8d=kroWPQ2oi%^ z*8c+7zNpO1a3o6#(Te`>nAu#TgDv^)XIF$nNzp&5n9_cKPoC%YS6}NeO{kzLJ1is< z&wxQfx2tz<6An((z1{9>YE)w-uKs29BK&I*cB7;_)O}94M0-)?>M-C}pT!n=mDvD> zp6llY`P|%cXl-HPyRp{5Ge?MR-<8HLGi?Okfjyab`4#tW>j_G)(iHop*Uy;%1ouc* zfei?L)e)!{5xuuMXktq)erci};0#`35Sr4rG>L$%MwvJ!v|R7LT(Cjo82f{tUC8Xp*WH@Ug- zlrJ`NA=__MZlmM6<0i)I5Li&X?{N^+kP+Za2aJlUip)s|g~THmpIAK#W+B4@a`;$9 z21HYV{kyo!Td2Ea@%aUXVOxR1z%yPJmxkm6-Kt3Nmz%YUfi%L?U@6NscarP+SNdBF z$#=)dFhe~1h5dO%sSSTT4*<`A?pVZ$C7HB{XotFKPwYPcp86Kv3upl~)oTN325zzN ztZ01(Jdb-!h~h+(&q`D}js3Qr4Qxe2Q;n4aq&6{67am@&IWS0}JTbb~x9>?0#agj& zkN7Xo-pRKLRuWI{Hz1b($Lm@32Y6Ygk`cwSKNgvH{17)YrXF6ygqSy-R;~ZlIk6}E za-|amA3^|7WdrN%p&XnE9LnxtHK6)tv8!%>56Y_AIS!0hYmyxs%oOvgYzWTxbhybr zx2@*K7U0y2vD)_kH46wy3k00=4P8!TbWoS#g>$VrI?Y&pAh|^}gV3040-Mskj#uli z-CTnk@2HYCT{*)v^rV|yJGBxffm$_1^M2m>?uSsR+@yC2I*tA9Zi7h-``C~>&&kI_ zn7pWxhz#LK!~%nP7LXBgFrs7*ZE_-kwmxjf=l8dgB=ldj`{AbAC_fzNn!ra^XVTZu zl#37iHu};E?=zX?$F36-kMCET=qQ#?e_y{;n$=A53N?c-I3?+7xO^l6E1g*X@Pr;> z)}Pk6vfXtp3atcab)(qw7ZLMtq1S5vrp?!~|8z4o%S!nMm*U1IMz=jhPSW@6N) zZg=*0@Tx?UFXJ{!{t-rdl`icQ+h_)V3ETA3?frEyv1fwPy$4KmG2Lad{(p`fdhuvN z4)ef`K8d#|4}rA7KYJ}KFJTHLy788kvsh|W!$scBzaYay^BjEAyI&4Us)@`!H*LOL zbGdP9+4tEEEt?ai3|4$GYJ1;g2Dg;*D-^2;9*)6!m{Dd(gZ-}*CtuXa3Gj63%NY(Q zp5IIPu3Vq!)eA!}q&_(6Q6;~u^Ec}ZMoP=%mF739Zcw!)^?4VCE1s}>F zQuc+1E5ycerV=VwNq+|GePrWj`;xJ+EPVNK^;tQYlisx>}O^y8>{g+Cj`eo8ThPk)mf)9F`=4c3b0hs9MEn<+{Z5*o z$Nf`qv$Vd7(k7W+KhO%BvafY3Wcl`16A{2`&gWAL2oS{b;=fY#1rGuzF1y{%17;W zquc!rZg|C^_*#dF_M|U*PegDM@vhRjzh7ll5_+F+D7m|ReG=-7`KnLhWSZ<-Hj?fA zM>v?5_1+Y`j3E27q<#jSlJcKiA>89fO1{IxB{Rs+UAFlhdr5Mrx|P23sUUquu6@G- z?LKU=Qp6v)=Sa%l+_o|MsgTOcKN*$dV-$-)XU3P#%KbzcZ^coBy999EQ27ukat(Pw z-;!r#mGBP)pe=Ynf2kjJGq;WAQhb*ssrA=_a~HDh9mR2Vn6YF0&y`LhB?I^S&IJEU za|`;%d-uFOqF|~@Vi*Upaa|)x^QSp8q^1oQ6z#S?0Fvy;(%lDN{(W|ROG})*mp##MbI5P#je*Z#fXQ^5jM^WM%2tT;+=aPJ0!`)|SP$DGQw>}91-IqII2mhge1 zjnExlLrp~T^Nb+_`jkA-7a=KHR;eLIn*RUpz2bX7pP}A1cwRAfWu!l^JSN9?q}&!T z<`q!s0c$(SoTw>Eb5XC?P%TX7y# z2bQI~$7I2-w)t^aspMO&_G9cIr%BncyV4BcFmUttS0^n$H`6I>_hip%XoxQ4wdU_4 zXdqo=;s$$_%Vw&l^bbuprmU02JbiYTo}>GEG1IxAD;p4pPX8C$ox&>mX{TDY1`*X^ zdS$SFY10?d1p4=DYb4WFUu-ufeTHL^4V=-+3u`hEl+C;-&-hpHjxJm*l0~c90Mbhe zqMxkEWuFK}o7a6z@s-&6Ri2b5P85C2*x>w{?94aDo)(#*fO}}XQmhBFLJmizKG?zz zTh4zL7t_=?5vJbh1vZPBs5C_EAapC|XDcw2p;Gv;F zSQDLbj8vzE8z!ckK}c50MFwhxS0|+3whWZ}!~#8S;G>62pR; zhhyLrd5CE3^GJGBwPm(%_eub3o7sLleruFv zIa|)?!!Vu*Xi=U-p6(GWDOIX{tJb{@Q-lwE*YsC3pWK)AvW+HQ5`i}-GiRTmVZl0y z2lcWj9ZS)_v<)@9_o5pWQmO~bx%`>`AR-Lx-$Jalks*QB+ke(?8&(9>-3#lFp^A}O zM%)qn)-!*nZGyaqVRDw{Xdx?NPqDeiwxLt;quHgAkRZ!vDzlu;KjH%-Fc}p2-d2+H zbGJIZH)KI2EvZ|7s$%dwRK=~z6OW7NW74?S;kEk$n@lQ+r(GIRX%AA!1x(ZIXU5Jf zUGd)Gsjgi3`RvhNr1G1t| zp-STS$Do;|ENjxAh(aDYtQ7ud9v5A#?z7)Ur;2Fc9jks(( z()#kG8LdPktw|b2$A^#A%s9QGf74{adojl7`jEX~h{*kMoTYzPrY?kA2?BsnV!I7+Xk&m|(#f0J&GzcH&{;`o3!j!vE8 z3)$0TN?zcqknj#zodURW2-eI1e&E$)lmswg7X(SJ{{(lI8^ z1ZFw7Y(B_xOU$__P-EX;f3}!gtrr1`uN zd2@vPp;@|l>q7C9;o97T>nAgai(;$UnDxnbUX95eeT%P`1=3Dh;9vG*W_>5qM9fC- z4x5j!4P>m>eyrbL?Z*HkaZBV_=@)*pT}KbQCak-vpZi7a{<^Tm!9AaZg#mAT)|jaV zd%-$^+MwjQlzFAUw&Kmf(13Hmz|!HnL&Y=qxWI(ev2|7Ds1!BOFaAQb^o-_I*HLqo zin#8f$J;Zl#8)@?j6KQ&0nnK0+jaH#n`$@GvagOTt=duP=+p2`szTl%tVoBnV;!A( z)Qj~``El%?6Irn>Crf@~0e+{Sw|?bWPd;5>Vc?0jUJt@*=WGgai}SOjx(3)fbo<7U zM-XDzClLqFNxeEc_O5ov^Fk}N>X=j-!#(5YD`6e=NqgvqOyu^8 zqz$rmXuos^;O(s{(>D2|uVk&@gVbI29NV6VBZ2Zy$k@OO*S=dm_~n^Y@0)A!#adcr z*F>es>B<4j(AKZ^q@uMyDn0pbO_2Y?sVB z>)LqE_x=7494nu>)PAqeqFlb?>QY2hJo$Ctmifv1G0YElLDwg$SvuU6M!RG*{yC4o57rM_5c1{5e+57Wdv)23Ti|K|1i&SGuIe9syIt6}gvq!&wyskSBKj$}k zR_A{v)wqy&Qm5oMD~I*S&h%Os-k1vD9{&09e;K2Q3id|eXLKYIoB$PRq=_ieZf)NP5VVfydqR{K56VkH%U9b zLM%&p?l03>yQ8~#QZj}gcoQd^bA>PnKggAzZu;fAO3C6p>WNz%`^U@Zvj{lln0iy5 zN5MMQ{D;x^)gaVfMUIW}a2#S?&Fy%$aCcQyi$xEaMJX zlzULfxW2IU43Q-r^^*X}ZGRSgu~8zun>S4hrURSh{M`h+^vQ>^Jj60(d^qQQBx7Wqlgj_*8s8(#Ab^b@%BrE6Ss#Ts7l|di zy$boX{xkHN)=T4*1gGs16!7b_d)&3CS$;ib__CYSmz9frBea#k`+S(fs21``=1iFe z=;+FIa^xT)U@|OvcXfb4fCC?=o2@vTEq|1qCFAuY>w4OxJ-%d7NPMoJO7aB2EHm&x zz?*C18fHYm-|=^8N*906QB>PhiGGW$pq^(elURL6tLL^8D1SQ8iT<|T=%t1AzH02- z-ORE|Ni=pr|Lyf(!-9pdJ&dj@nVWk1w}RVm9=xp?TN_ID!5|Ynr2PX*^k6GY;_p{S zVOJCJ?9$Wwn(0%;c~!SZmMezR6mp6b{*s=Effh&&wcD>if9-a1(0w!CPRj=nB)&r)z-te$(O6i|!5wQm0$6uO(l1h(Az1`?h zvtj9<&hu}jA5@*YQ%aISZt*r1y+FC;?41AF+yn6Ero~njkH4%vUWvmQDSrAe ziF@Y+w)E(Jli+17){xj)s97Azl$-*$@?{NOS8dyRjzK3*Hb3(oH|sQV?fQv#|9Dri z>u#FebM9U!+Z3g(_+jEa{1`A(-FkV@g{*&36cH8?N@i*Q2-lUq&`x<{bcuLBLv6AFcC#_p1UrsWY!>3m+@J( zDZw8Tf6(nuy3gpEBJx(ID?*l%mL>noS-8&EYfwjE#RcHWx#+s2BdEtwcU_#5v%Me2 zJc2ZveF@gRr0b^nl(+=FLoZt3pD_dOc(}2nIqV8MTi6+>6zImc?D>9UcB1f9pRybl z>oA=$S(+r%6qt#)v={d{=Daamktf;u@kV5^p(5u`k7k{K?qZF#&VqGf!%sO4_Kmt; zVs$D7Al~O-Y_C7FzX2U*=T!|x%+2=UtrE1JO)1C(!U?@vhl6i3x>@h(^8Yx z%53X$jes71ljZOukjmS;@TYC(F=Z9FZr^`rfkaU)R#_X^_)L|#9hzO8#ctFeVwGo~ zXXP>mTSTP?+0VF5A@O@tEnr6tz)o;~<(H|)xnZj=>4$R8I@&&%`~*pt{=aPSG9u*qYz&SDIBXu+=4r&-=- zT9R1pFIB-Oi_GO40US(UC8;aattLMX!vYQy+A(YO)|IM%I`>xW{d?oE80<)vqjRtH z(@dU0`xkUzPA&*8P~H#Gwz`#ZWj-UI#rjFW{9$*E?_$7-j8r1UPE^%R{(@Kg?L!)_ zrakmz0& zJUA4bq3>SU45t9WK%ltnW2?qcP?nL=apZ&ucKb z*evqTC|@r32;}DH$H|}voPNVo%Fyd}bWFck_uRKn>3vF=UqwCbHOIevsf>chv&)_; zn>!<$$Z!+Adl=_!tni-{+w;P;I)kBIAByNJZ;yyv_yh=j+9WW2c@S4&HxBr#Ax zvxD4z_RWBf5AAj%2W6GGy{gd+U6J7PupM)s0cH2JNm_-mRw}^pP5ZcanS1K#bTIWe zi*hm=j@9e4nV|C)u30WMh=m_R`+bG}(}8M-GY)&XNq!CPRk`ne5CT0rVe} zKdBOIFott0)sAbdvtJ*Tu%fg0F%EK+8^kUPA5$M6JMtYZ8E}C;>*Xbq-DFks_iyni z@g2v%H#<8iTNr;Ak#4EoKY`MeMax?>j?e+0E(M_=ekpT6 zVHggj%BP;tW~XoBP1aVR`#GF7UGd%oRG&C5ZXJ3`2`fO(D+j`a_#ge}m(gJ!cosgw z+$*>tlKkDmD_hzzHKk93g13FS$Qo4dluE(5AFP%`d&n0hg@YHQhIcv8T0cK)JZ)eg z>&?;$TF$jK=`UVg=}fST*c{#l^IZum_! z1G1vhZ!{a@r_C;ZwpVC=I>IukzjWrfw4XoRy*ey?bLBW!_}$TJ73uV`_pm8FgnR=kgFLq9Uak}e0gUk$tR-jb+=Mvb;`U#(v_he(l;i?f3XdG$6~ zVW$PoEi8tJBWL(T(Qa6N_Yq5BwXGAnJ6z!UZ$W|19ZOjf=5=FkFMMZUqwj&hNhq@u zi+>!?3UQSYuvP>RtY5k_3ts0_>$9Tzcx<*%#xv@WFulEYdKX0Xd`qKg^iS|J;4!GV zs`fC5`B?VomPDFBt$dks8zaRLRtj+reF}h9_>v=4lMv`&elFA|n+VF?4VXfKb zMj_)WP5WpsopN*eWm3e(RLStGkUcF=o+i8zudM`Kxh1a?g5h|jFLqcYf;pbfz7;u_ zVWFm4VbVGc+oZqEGGZoB=siF0yffEO*GMn9Yid3n+c}b@EM?ukn%yU$ZnX8XT2;+t z;VM*|y+&Ow89DF3(%pn^Vlj*e;Zx#$rbxnq;lb0rhH^@@v-q4xZ0VhkZB1AFq!XlT z|EA$@zW{Z3 zZd9z)d6L0>j7IvyD5<%nCQIw3#-)!v?ThEK6KMxxB(daCmppI&#<+4HVJZ;Sr!Yl1 zoeuh2>pRNR8E9WC(f!jF`UGjiH)qo}sB&y#e{Pc9`T+M$WIa`=p06n{Pw{ zR6L(65}e78WGNuqA;VONobks8Uz-oDItbrJ`1-VZH^N`FMg1F%Ob)zGw(R{>gA}qJ zHa^g@kLw#LXCB<8`Bk0QBkFI*`%^61l|h@{FwCuI9Pc?g!Vx;s?Xa(%Tw&fgI^7s{ zBn6+Hu7apgsWCk?>WBT2`~aPP@UGOR8^C4ed_!Z^x}izp>O{n`EM4{ybV6iL9&+X9 zm-|j(y_MD&5Fw>PJ$Up{H$e?S7Cy<$?>19XT{@d}tWTISA4x>W{q0aSon*_ud%IuO zB_Y&lqQ+kr%0!ySZ>BfBb$ORu!q$91_a2qLQ(;x78IN?FDVKpT1WA)QbNTmApC6qB zM52dZCIfW?5vsMs^x+8kOryh94Aysb(IKT40#sQ|mX}Q90-J-ecPH9uP}1G+SuS@` zuJfPHIig9Apm!(}Yg{Plv$tO?tpoEvD)n19M#waujeu^Z{S5vqVV)YIPWy;$T1N3H zg~6Z}$$=FG?A|6FGdRi|C82fCBjNa^elKTtK8{MfNmfwTeX95#idqQ@G&J`^$!QuI zc%%BuuFZVrzm*2P3pl~{KEwXbmh!DNIYUQIH~Q<-ZNkk4e6bH87JTjvxIb93b7ebc zR*>jmS1clylWODwJ|E=s{5_{HT`ug98*(Z2jxtNiE`Oaa(tFobDA{e)d+jkcMAKgK zgraN)-d3C?;R657Ja!bbHsl|Wm@d0NAw%0r2yzWS#$>N`5#qS463OXP>8oYpe?}z_ z#q#T1vS=<%GOAPTZ2?QfE!3_{$uWtd44<6f)&uo;b>mN~U6{-&qz<+lgVlHmIlTz} zY@sHTr>p)TN{IRnj6_kI<*uSD%VhOkGODYA$nZm!$yv~X!EwY9B^tq?fVve&Ixk{j z0qwcNk1%lz`-8_H;^vp)es_}|nBib!uysn}!CNL)%WfDNwdrlT%5H{VMIOxS3yBTO z?1@aDwui)zn+9s@95)?^+k9QRB=`L8|SC^t}I(Ogd^rwtNCbZC^X3QGPQ8 z`5Q2ItB+>?6l0Tc4XY`oI%~h4bDReCcJn|^UtW=u#__;zZ}w%1Dr$$#QwZ{=($Vzz z&qjr9g8E4e5ii)FZ|J6fF?7Wn{b}$j6RWoy;ezoDcJ@R)%^z{*cGyDguMiOqHcd2! z4YTMgM}CyVDz%(c?`YByM{%Jl!CBN{W1ruFBq!p7)mN8Edl_EUIFT}#QGA5I;#Bag zuPF$Kaz4MS7Cf#I^-6&6q`xsTpv~_rkjobIQc)vM`4YQEMnXk(v7y05^tRFz-Qu}v z<(H10?T%%ad0&)sI`T72fZimGk6taTD+9U8*=vVqnM_+9&>)0ZM!?krNe`Cn^CLdX z&$xf{4Rf`tyzTFhTf}+3StjEx+O*W{5gGq1(PeR@r(vk4MRsz}V)DRY^r-qM>zklc zj73F&j&qiIf7{4>MnR8*SI&qHM(-Et5fCR*Hi;VmX|mOW`nI@(CXUaPp|}e zclb|RD3=5aoKu^N0jXadFVPc3!4uSNB-)FJqTkeHbeTVnb}o(f3Dr7gv8NXObi3zM zX?HsMZtc61Hp0HG&bJ(*aS4&{gxJxCdstnj+xIVT)sq`)7^ODQJugr)jWk&CX-aLx zb3rcBearC)yd3>m-qm0dlSqrOSf&S?Ok(d>BRZ~I%tFzc$#>@N{}(p1VF=+FNNOPf z)snwgw8-mxO_msFMMX+NZCTgmr_y+s*x=hsA5qxW2A?Q)fijC7uG5G>Hl2++P~C41 z31@2lev8*W*|}JlUgmXmGH+ffa#CWYYGsX$XP?euv38<&h!dQ54)Z2ntB$3diA+*E=qrNj&U+P`t^9QE)yG#A4waY9@qrrbPxUW+ezYaY*u9zB3Z3sOMY@OdF!J+E-=6e^HEt52 zoc47N%MV<)pT?FPaoz;1k`j#8xYX@tM?2f%^Lel3BG*%Ye%d3yv&_O}gko!TyIgzz0Z@2mlD-R3SukQ&cs`0!{NU;Beta2U zqYAZv?%C7-pj>$vsAQ2mm@JxQ`Yn-A7*yCKW@X#uj`ZdfgZE7-JEqXxFHhptWC$bk z6V6!YfvCsz&NEb)fBV~9UP-ln#!0)>o7i`N<%nSAzqRM@AbZ7F1$h);^fxgVT$X^Z zik1?QuETVt^E19U4ZQs+Q|gpmUudho`zJ50dPw1r=uIqdA$a@rzrVG3T}s@WC(JTD z#Y?Bd5E7vqrI63&r9MB-!#YBdQI{Fw(BKtoW{A#dRx|rC<=0L zuXv9x$G&q3th<$xnfhg+a#}iObxekn;zK(X)EIO0bb5W^Xb&@RknyXrevBRM;PE7> zok5+#ZNtTDg;#i>ODo7azK1U#G8_NC zc{ytKeEwm?Fg6qzl@0Yk9u7VvXCX$7-dojp8dJ@T;+uYqW<3?7F>FyYDbdYXLBw%^ zbAqU9cmPBvYAHu0OemD@2PKGTIp^t328l6`w0`tIZ0EY^((->%p57a&|3lp8{|sRI dKf2Kk?allLw(?GmXaeB#OiNF*Qr+Uy{{xCOCzJpH literal 0 HcmV?d00001 diff --git a/plugins/ui/src/deephaven/ui/hooks/__init__.py b/plugins/ui/src/deephaven/ui/hooks/__init__.py index 35588611c..a3cc11bfc 100644 --- a/plugins/ui/src/deephaven/ui/hooks/__init__.py +++ b/plugins/ui/src/deephaven/ui/hooks/__init__.py @@ -4,6 +4,12 @@ from .use_state import use_state from .use_ref import use_ref from .use_table_listener import use_table_listener +from .use_table_data import use_table_data +from .use_column_data import use_column_data +from .use_row_data import use_row_data +from .use_row_list import use_row_list +from .use_cell_data import use_cell_data + __all__ = [ "use_callback", @@ -12,4 +18,9 @@ "use_state", "use_ref", "use_table_listener", + "use_table_data", + "use_column_data", + "use_row_data", + "use_row_list", + "use_cell_data", ] diff --git a/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py b/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py new file mode 100644 index 000000000..6ecc2b2e9 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_cell_data.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any +import pandas as pd + +from deephaven.table import Table + +from .use_table_data import use_table_data +from ..types import Sentinel + + +def _cell_data(data: pd.DataFrame, is_sentinel: bool) -> None: + """ + Return the first cell of the table. + + Args: + data: pd.DataFrame: The table to extract the cell from. + is_sentinel: bool: Whether the sentinel value was returned. + + Returns: + Any: The first cell of the table. + """ + try: + return data if is_sentinel else data.iloc[0, 0] + except IndexError: + # if there is a static table with no rows, we will get an IndexError + raise IndexError("Cannot get row list from an empty table") + + +def use_cell_data(table: Table, sentinel: Sentinel = None) -> Any: + """ + Return the first cell of the table. The table should already be filtered to only have a single cell. + + Args: + table: Table: The table to extract the cell from. + sentinel: Sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + + Returns: + Any: The first cell of the table. + """ + return use_table_data(table, sentinel, _cell_data) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_column_data.py b/plugins/ui/src/deephaven/ui/hooks/use_column_data.py new file mode 100644 index 000000000..bd2e3b410 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_column_data.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import pandas as pd + +from deephaven.table import Table + +from .use_table_data import use_table_data +from ..types import Sentinel, ColumnData + + +def _column_data(data: pd.DataFrame, is_sentinel: bool) -> ColumnData: + """ + Return the first column of the table as a list. + + Args: + data: pd.DataFrame: The table to extract the column from. + is_sentinel: bool: Whether the sentinel value was returned. + + Returns: + ColumnData: The first column of the table as a list. + """ + try: + return data if is_sentinel else data.iloc[:, 0].tolist() + except IndexError: + # if there is a static table with no columns, we will get an IndexError + raise IndexError("Cannot get column data from an empty table") + + +def use_column_data(table: Table, sentinel: Sentinel = None) -> ColumnData | Sentinel: + """ + Return the first column of the table as a list. The table should already be filtered to only have a single column. + + Args: + table: Table: The table to extract the column from. + sentinel: Sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + + Returns: + ColumnData | Sentinel: The first column of the table as a list or the + sentinel value. + """ + return use_table_data(table, sentinel, _column_data) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_row_data.py b/plugins/ui/src/deephaven/ui/hooks/use_row_data.py new file mode 100644 index 000000000..8e8fbf226 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_row_data.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pandas as pd + +from deephaven.table import Table + +from .use_table_data import use_table_data +from ..types import Sentinel, RowData + + +def _row_data(data: pd.DataFrame, is_sentinel: bool) -> RowData: + """ + Return the first row of the table as a dictionary. + + Args: + data: pd.DataFrame: The dataframe to extract the row from or the sentinel value. + is_sentinel: bool: Whether the sentinel value was returned. + + Returns: + RowData: The first row of the table as a dictionary. + """ + try: + return data if is_sentinel else data.iloc[0].to_dict() + except IndexError: + # if there is a static table with no rows, we will get an IndexError + raise IndexError("Cannot get row data from an empty table") + + +def use_row_data(table: Table, sentinel: Sentinel = None) -> RowData | Sentinel: + """ + Return the first row of the table as a dictionary. The table should already be filtered to only have a single row. + + Args: + table: Table: The table to extract the row from. + sentinel: Sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + + Returns: + RowData | Sentinel: The first row of the table as a dictionary or the sentinel value. + """ + return use_table_data(table, sentinel, _row_data) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_row_list.py b/plugins/ui/src/deephaven/ui/hooks/use_row_list.py new file mode 100644 index 000000000..42d8dd1ef --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_row_list.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any +import pandas as pd + +from deephaven.table import Table + +from .use_table_data import use_table_data +from ..types import Sentinel + + +def _row_list(data: pd.DataFrame, is_sentinel: bool) -> list[Any]: + """ + Return the first row of the table as a list. + + Args: + data: pd.DataFrame | Sentinel: The dataframe to extract the row from or the sentinel value. + is_sentinel: bool: Whether the sentinel value was returned. + + Returns: + list[Any]: The first row of the table as a list. + """ + try: + return data if is_sentinel else data.iloc[0].values.tolist() + except IndexError: + # if there is a static table with no rows, we will get an IndexError + raise IndexError("Cannot get row list from an empty table") + + +def use_row_list(table: Table, sentinel: Sentinel = None) -> list[Any] | Sentinel: + """ + Return the first row of the table as a list. The table should already be filtered to only have a single row. + + Args: + table: Table: The table to extract the row from. + sentinel: Sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + + Returns: + list[Any] | Sentinel: The first row of the table as a list or the sentinel value. + """ + return use_table_data(table, sentinel, _row_list) diff --git a/plugins/ui/src/deephaven/ui/hooks/use_table_data.py b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py new file mode 100644 index 000000000..2b7ab865b --- /dev/null +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_data.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from functools import partial +from typing import Callable +import pandas as pd + +from deephaven.table import Table +from deephaven.table_listener import TableUpdate +from deephaven.pandas import to_pandas +from deephaven.execution_context import ExecutionContext, get_exec_ctx +from deephaven.server.executors import submit_task +from deephaven.update_graph import has_exclusive_lock + +import deephaven.ui as ui + +from ..types import Sentinel, TableData, TransformedData + + +def _deferred_update(ctx: ExecutionContext, func: Callable[[], None]) -> None: + """ + Call the function within an execution context. + + Args: + ctx: ExecutionContext: The execution context to use. + func: Callable[[], None]: The function to call. + """ + with ctx: + func() + + +def _on_update( + ctx: ExecutionContext, + func: Callable[[], None], + executor_name: str, + update: TableUpdate, + is_replay: bool, +) -> None: + """ + Call the function within an execution context, deferring the call to a thread pool. + + Args: + ctx: ExecutionContext: The execution context to use. + func: Callable[[], None]: The function to call. + executor_name: str: The name of the executor to use. + update: TableUpdate: The update to pass to the function. + is_replay: True if the update is a replay, False otherwise. + """ + submit_task(executor_name, partial(_deferred_update, ctx, func)) + + +def _get_data_values(table: Table, sentinel: Sentinel): + """ + Called to get the new data and is_sentinel values when the table updates. + + Args: + table: Table: The table that updated. + sentinel: Sentinel: The sentinel value to return if the table is empty and refreshing. + + Returns: + tuple[pd.DataFrame | Sentinel, bool]: The table data and whether the sentinel value was + returned. + """ + data = to_pandas(table) + if table.is_refreshing: + if data.empty: + return sentinel, True + else: + return data, False + else: + return data, False + + +def _set_new_data( + table: Table, + sentinel: Sentinel, + set_data: Callable[[pd.DataFrame | Sentinel], None], + set_is_sentinel: Callable[[bool], None], +) -> None: + """ + Called to set the new data and is_sentinel values when the table updates. + + Args: + table: Table: The table that updated. + sentinel: Sentinel: The sentinel value to return if the table is empty. + set_data: Callable[[pd.DataFrame | Sentinel], None]: The function to call to set the new data. + set_is_sentinel: Callable[[bool], None]: The function to call to set the is_sentinel value. + """ + new_data, new_is_sentinel = _get_data_values(table, sentinel) + set_data(new_data) + set_is_sentinel(new_is_sentinel) + + +def _table_data(data: pd.DataFrame, is_sentinel: bool) -> TableData: + """ + Returns the table as a dictionary. + + Args: + data: pd.DataFrame | Sentinel: The dataframe to extract the table data from or the + sentinel value. + is_sentinel: bool: Whether the sentinel value was returned. + + Returns: + TableData: The table data. + """ + return data if is_sentinel else data.to_dict(orient="list") + + +def use_table_data( + table: Table, + sentinel: Sentinel = None, + transform: Callable[ + [pd.DataFrame | Sentinel, bool], TransformedData | Sentinel + ] = None, +) -> TableData | Sentinel | TransformedData: + """ + Returns a dictionary with the contents of the table. Component will redraw if the table + changes, resulting in an updated frame. + + Args: + table: Table: The table to listen to. + sentinel: Sentinel: The sentinel value to return if the table is ticking but empty. Defaults to None. + transform: Callable[[pd.DataFrame | Sentinel, bool], tuple[pd.DataFrame | Sentinel, bool]]: A + function to transform the table data and is_sentinel values. Defaults to None, which will + return the data as TableData. + + Returns: + TableData | Sentinel: The table data or the sentinel value. + """ + initial_data, initial_is_sentinel = _get_data_values(table, sentinel) + data, set_data = ui.use_state(initial_data) + is_sentinel, set_is_sentinel = ui.use_state(initial_is_sentinel) + + if not transform: + transform = _table_data + + ctx = get_exec_ctx() + + # Decide which executor to submit callbacks to now, while we hold any locks from the caller + if has_exclusive_lock(ctx.update_graph): + executor_name = "serial" + else: + executor_name = "concurrent" + + table_updated = lambda: _set_new_data(table, sentinel, set_data, set_is_sentinel) + + ui.use_table_listener(table, partial(_on_update, ctx, table_updated, executor_name)) + + 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 1e994aaaf..9e14eb17c 100644 --- a/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py +++ b/plugins/ui/src/deephaven/ui/hooks/use_table_listener.py @@ -1,15 +1,14 @@ from __future__ import annotations from functools import partial -from typing import Callable, Literal +from typing import Callable from deephaven.table import Table from deephaven.table_listener import listen, TableUpdate, TableListener from deephaven.execution_context import get_exec_ctx, ExecutionContext from .use_effect import use_effect - -LockType: Literal["shared", "exclusive"] +from ..types import LockType def listener_with_ctx( @@ -84,6 +83,10 @@ def use_table_listener( replay_lock: LockType: The lock type used during replay, default is ‘shared’, can also be ‘exclusive’. """ + if not table.is_refreshing: + # if the table is not refreshing, there is nothing to listen to + return + def start_listener() -> Callable[[], None]: """ Start the listener. Returns a function that can be called to stop the listener by the use_effect hook. diff --git a/plugins/ui/src/deephaven/ui/types/__init__.py b/plugins/ui/src/deephaven/ui/types/__init__.py new file mode 100644 index 000000000..e4c869a7c --- /dev/null +++ b/plugins/ui/src/deephaven/ui/types/__init__.py @@ -0,0 +1 @@ +from .types import * diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py new file mode 100644 index 000000000..d7e26fec4 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -0,0 +1,9 @@ +from typing import Any, List, Dict, Literal + +Sentinel = Any +ColumnName = str +ColumnData = List[Any] +RowData = Dict[ColumnName, Any] +TableData = Dict[ColumnName, ColumnData] +LockType = Literal["shared", "exclusive"] +TransformedData = Any diff --git a/plugins/ui/test/deephaven/ui/test_hooks.py b/plugins/ui/test/deephaven/ui/test_hooks.py index c953fa3eb..fe76fb5e2 100644 --- a/plugins/ui/test/deephaven/ui/test_hooks.py +++ b/plugins/ui/test/deephaven/ui/test_hooks.py @@ -135,16 +135,9 @@ def _test_memo(fn=lambda: "foo", a=1, b=2): self.assertEqual(result, "biz") self.assertEqual(mock.call_count, 1) - def test_table_listener(self): + def verify_table_updated(self, table_writer, table, update): from deephaven.ui.hooks import use_table_listener - from deephaven import time_table, new_table, DynamicTableWriter from deephaven.table_listener import TableUpdate - import deephaven.dtypes as dht - - column_definitions = {"Numbers": dht.int32, "Words": dht.string} - - table_writer = DynamicTableWriter(column_definitions) - table = table_writer.table event = threading.Event() @@ -157,11 +150,346 @@ def _test_table_listener(replayed_table_val=table, listener_val=listener): render_hook(_test_table_listener) - table_writer.write_row(1, "Testing") + table_writer.write_row(*update) if not event.wait(timeout=1.0): assert False, "listener was not called" + def test_table_listener(self): + from deephaven import time_table, new_table, DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Numbers": dht.int32, "Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + self.verify_table_updated(table_writer, table, (1, "Testing")) + + def test_table_data(self): + from deephaven.ui.hooks import use_table_data + from deephaven import new_table + from deephaven.column import int_col + + table = new_table( + [ + int_col("X", [1, 2, 3]), + int_col("Y", [2, 4, 6]), + ] + ) + + def _test_table_data(t=table): + return use_table_data(t) + + render_result = render_hook(_test_table_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = {"X": [1, 2, 3], "Y": [2, 4, 6]} + + self.assertEqual(result, expected) + + def test_empty_table_data(self): + from deephaven.ui.hooks import use_table_data + from deephaven import new_table + + empty = new_table([]) + + def _test_table_data(t=empty): + return use_table_data(t) + + render_result = render_hook(_test_table_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = {} + + self.assertEqual(result, expected) + + def test_ticking_table_data(self): + from deephaven.ui.hooks import use_table_data + from deephaven import DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Numbers": dht.int32, "Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + def _test_table_data(t=table): + return use_table_data(t, sentinel="sentinel") + + render_result = render_hook(_test_table_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + # the initial render should return the sentinel value since the table is empty + self.assertEqual(result, "sentinel") + + self.verify_table_updated(table_writer, table, (1, "Testing")) + + render_result = render_hook(_test_table_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = {"Numbers": [1], "Words": ["Testing"]} + + self.assertEqual(result, expected) + + def test_column_data(self): + from deephaven.ui.hooks import use_column_data + from deephaven import new_table + from deephaven.column import int_col + + table = new_table( + [ + int_col("X", [1, 2, 3]), + ] + ) + + def _test_column_data(t=table): + return use_column_data(t) + + render_result = render_hook(_test_column_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = [1, 2, 3] + + self.assertEqual(result, expected) + + def test_empty_column_data(self): + from deephaven.ui.hooks import use_column_data + from deephaven import new_table + + empty = new_table([]) + + def _test_column_data(t=empty): + return use_column_data(t) + + self.assertRaises(IndexError, render_hook, _test_column_data) + + def test_ticking_column_data(self): + from deephaven.ui.hooks import use_column_data + from deephaven import DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + def _test_column_data(t=table): + return use_column_data(t, sentinel="sentinel") + + render_result = render_hook(_test_column_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + # the initial render should return the sentinel value + self.assertEqual(result, "sentinel") + + self.verify_table_updated(table_writer, table, ("Testing",)) + + render_result = render_hook(_test_column_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = ["Testing"] + + self.assertEqual(result, expected) + + def test_row_data(self): + from deephaven.ui.hooks import use_row_data + from deephaven import new_table + from deephaven.column import int_col + + table = new_table( + [ + int_col("X", [1]), + int_col("Y", [2]), + ] + ) + + def _test_row_data(t=table): + return use_row_data(t) + + render_result = render_hook(_test_row_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = {"X": 1, "Y": 2} + + self.assertEqual(result, expected) + + def test_empty_row_data(self): + from deephaven.ui.hooks import use_row_data + from deephaven import new_table + + empty = new_table([]) + + def _test_row_data(t=empty): + return use_row_data(t) + + self.assertRaises(IndexError, render_hook, _test_row_data) + + def test_ticking_row_data(self): + from deephaven.ui.hooks import use_row_data + from deephaven import DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Numbers": dht.int32, "Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + def _test_row_data(t=table): + return use_row_data(t, sentinel="sentinel") + + render_result = render_hook(_test_row_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + # the initial render should return the sentinel value + self.assertEqual(result, "sentinel") + + self.verify_table_updated(table_writer, table, (1, "Testing")) + + render_result = render_hook(_test_row_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = {"Numbers": 1, "Words": "Testing"} + + self.assertEqual(result, expected) + + def test_row_list(self): + from deephaven.ui.hooks import use_row_list + from deephaven import new_table + from deephaven.column import int_col + + table = new_table( + [ + int_col("X", [1]), + int_col("Y", [2]), + ] + ) + + def _use_row_list(t=table): + return use_row_list(t) + + render_result = render_hook(_use_row_list) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = [1, 2] + + self.assertEqual(result, expected) + + def test_empty_row_list(self): + from deephaven.ui.hooks import use_row_list + from deephaven import new_table + + empty = new_table([]) + + def _test_row_list(t=empty): + return use_row_list(t) + + self.assertRaises(IndexError, render_hook, _test_row_list) + + def test_ticking_row_list(self): + from deephaven.ui.hooks import use_row_list + from deephaven import DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Numbers": dht.int32, "Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + def _test_row_list(t=table): + return use_row_list(t, sentinel="sentinel") + + render_result = render_hook(_test_row_list) + + result, rerender = itemgetter("result", "rerender")(render_result) + + # the initial render should return the sentinel value + self.assertEqual(result, "sentinel") + + self.verify_table_updated(table_writer, table, (1, "Testing")) + + render_result = render_hook(_test_row_list) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = [1, "Testing"] + + self.assertEqual(result, expected) + + def test_cell_data(self): + from deephaven.ui.hooks import use_cell_data + from deephaven import new_table + from deephaven.column import int_col + + table = new_table( + [ + int_col("X", [1]), + ] + ) + + def _test_cell_data(t=table): + return use_cell_data(t) + + render_result = render_hook(_test_cell_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = 1 + + self.assertEqual(result, expected) + + def test_empty_cell_data(self): + from deephaven.ui.hooks import use_cell_data + from deephaven import new_table + + empty = new_table([]) + + def _use_cell_data(t=empty): + return use_cell_data(t) + + self.assertRaises(IndexError, render_hook, _use_cell_data) + + def test_ticking_cell_data(self): + from deephaven.ui.hooks import use_cell_data + from deephaven import DynamicTableWriter + import deephaven.dtypes as dht + + column_definitions = {"Words": dht.string} + + table_writer = DynamicTableWriter(column_definitions) + table = table_writer.table + + def _test_cell_data(t=table): + return use_cell_data(t, sentinel="sentinel") + + render_result = render_hook(_test_cell_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + # the initial render should return the sentinel value + self.assertEqual(result, "sentinel") + + self.verify_table_updated(table_writer, table, ("Testing",)) + + render_result = render_hook(_test_cell_data) + + result, rerender = itemgetter("result", "rerender")(render_result) + + expected = "Testing" + + self.assertEqual(result, expected) + if __name__ == "__main__": unittest.main() From ab8cc67cf3644cbe814a4cc4e833a771a71eca15 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 Dec 2023 15:11:01 -0600 Subject: [PATCH 3/8] fix: Matplotlib input table and execution context not working (#185) `KeyedArrayBackedMutableTable` was renamed, which was causing this to break. Moved to the python api and also fixed some issues with the execution context ``` from deephaven.plugin.matplotlib import TableAnimation from deephaven import pandas as dhpd from deephaven import time_table import matplotlib.pyplot as plt import seaborn as sns import pandas as pd # Create a ticking table with the cosine function tt = time_table("PT1S").update(["X = 0.2 * i", "Y = Math.cos(X)"]) fig, ax = plt.subplots() # Create a new figure # This function updates a figure def update_fig(data, update): # Clear the axes (don't draw over old lines) ax.clear() # Convert the X and Y columns in `tt` to a DataFrame df = dhpd.to_pandas(tt.view(["X", "Y"])) # Draw the line plot sns.lineplot(df, x="X", y="Y") # Create our animation. It will listen for updates on `tt` and call `update_fig` whenever there is an update line_plot_ani = TableAnimation(fig, tt, update_fig) ``` --------- Co-authored-by: mikebender --- .../deephaven/plugin/matplotlib/__init__.py | 2 + .../plugin/matplotlib/figure_type.py | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py index b6ccdf350..b1951ef9c 100644 --- a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py +++ b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py @@ -5,6 +5,8 @@ import matplotlib.pyplot as plt from matplotlib.animation import Animation import itertools +from deephaven.execution_context import get_exec_ctx + __version__ = "0.3.0.dev0" diff --git a/plugins/matplotlib/src/deephaven/plugin/matplotlib/figure_type.py b/plugins/matplotlib/src/deephaven/plugin/matplotlib/figure_type.py index bf016e6cf..74f80d533 100644 --- a/plugins/matplotlib/src/deephaven/plugin/matplotlib/figure_type.py +++ b/plugins/matplotlib/src/deephaven/plugin/matplotlib/figure_type.py @@ -2,8 +2,11 @@ from weakref import WeakKeyDictionary, WeakSet from matplotlib.figure import Figure from deephaven.plugin.object_type import Exporter, FetchOnlyObjectType +from deephaven.execution_context import get_exec_ctx from threading import Timer from deephaven.liveness_scope import liveness_scope +from deephaven import input_table, new_table +from deephaven.column import string_col, int_col # Name of the matplotlib figure object that was export NAME = "matplotlib.figure.Figure" @@ -49,34 +52,33 @@ def call_it(): # width: The width of panel displaying the figure # height: The height of the panel displaying the figure def _make_input_table(figure): - from deephaven import new_table - from deephaven.column import string_col, int_col - import jpy - - input_table = None + input_t = None revision = 0 + # Need to track the execution context used so we can use it again when updating the revision + exec_ctx = get_exec_ctx() + t = new_table( [ string_col("key", ["revision", "width", "height"]), int_col("value", [revision, 640, 480]), ] ) - input_table = jpy.get_type( - "io.deephaven.engine.table.impl.util.KeyedArrayBackedMutableTable" - ).make(t.j_table, "key") + + input_t = input_table(init_table=t, key_cols=["key"]) # TODO: Add listener to input table to update figure width/height @debounce(0.1) def update_revision(): - nonlocal revision - revision = revision + 1 - input_table.getAttribute("InputTable").add( - new_table( - [string_col("key", ["revision"]), int_col("value", [revision])] - ).j_table - ) + with exec_ctx: + nonlocal revision + revision = revision + 1 + input_t.add( + new_table( + [string_col("key", ["revision"]), int_col("value", [revision])] + ) + ) def handle_figure_update(self, value): # Check if we're already drawing this figure, and the stale callback was triggered because of our call to savefig @@ -86,7 +88,7 @@ def handle_figure_update(self, value): figure.stale_callback = handle_figure_update - return input_table + return input_t def _get_input_table(figure): @@ -117,8 +119,8 @@ def is_type(self, object) -> bool: return isinstance(object, Figure) def to_bytes(self, exporter: Exporter, figure: Figure) -> bytes: - with liveness_scope() as scope: - input_table = _get_input_table(figure) - exporter.reference(input_table) - scope.preserve(input_table) + with liveness_scope() as scope, get_exec_ctx(): + input_t = _get_input_table(figure) + exporter.reference(input_t) + scope.preserve(input_t) return _export_figure(figure) From e4d553095f0313979f6034188662937b5756ebf9 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 22 Dec 2023 15:14:39 -0600 Subject: [PATCH 4/8] build: Version bump matplotlib to v0.3.0 (#187) --- plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py index b1951ef9c..eba8f94c3 100644 --- a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py +++ b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py @@ -8,7 +8,7 @@ from deephaven.execution_context import get_exec_ctx -__version__ = "0.3.0.dev0" +__version__ = "0.3.0" def _init_theme(): From ba2e900378c7ec50de2fc8e52d1e7c76d2210dbe Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Wed, 3 Jan 2024 10:46:47 -0500 Subject: [PATCH 5/8] fix: plotly-express error when running from docker (#193) - plotly-express JS plugin is included in the docker container, so no need to install it separately --- docker/config/deephaven.prop | 1 - 1 file changed, 1 deletion(-) diff --git a/docker/config/deephaven.prop b/docker/config/deephaven.prop index 1c568c3d2..f415db0c6 100644 --- a/docker/config/deephaven.prop +++ b/docker/config/deephaven.prop @@ -4,7 +4,6 @@ deephaven.console.type=python # Add all plugins that you want installed here deephaven.jsPlugins.@deephaven/js-plugin-ui=/opt/deephaven/config/plugins/plugins/ui/src/js -deephaven.jsPlugins.@deephaven/js-plugin-plotly-express=/opt/deephaven/config/plugins/plugins/plotly-express/src/js # Anonymous authentication so we don't need to put in a password AuthHandlers=io.deephaven.auth.AnonymousAuthenticationHandler \ No newline at end of file From a7daf6a635576f200580f4eaac6f5119570c5607 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 3 Jan 2024 11:39:51 -0600 Subject: [PATCH 6/8] build: Version bump matplotlib to v0.4.0.dev0 (#188) Also removed import that I neglected to remove on matplotlib fixes --- plugins/matplotlib/setup.cfg | 2 +- .../matplotlib/src/deephaven/plugin/matplotlib/__init__.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/matplotlib/setup.cfg b/plugins/matplotlib/setup.cfg index b8416cd38..5052d68f7 100644 --- a/plugins/matplotlib/setup.cfg +++ b/plugins/matplotlib/setup.cfg @@ -3,7 +3,7 @@ name = deephaven-plugin-matplotlib description = Deephaven Plugin for matplotlib long_description = file: README.md long_description_content_type = text/markdown -version = attr:deephaven.plugin.matplotlib.__version__ +version = 0.4.0.dev0 url = https://github.com/deephaven/deephaven-plugins project_urls = Source Code = https://github.com/deephaven/deephaven-plugins diff --git a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py index eba8f94c3..788617a5c 100644 --- a/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py +++ b/plugins/matplotlib/src/deephaven/plugin/matplotlib/__init__.py @@ -5,10 +5,6 @@ import matplotlib.pyplot as plt from matplotlib.animation import Animation import itertools -from deephaven.execution_context import get_exec_ctx - - -__version__ = "0.3.0" def _init_theme(): From df04f2941935a2203424e1789e495621e847068d Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 4 Jan 2024 15:49:26 -0600 Subject: [PATCH 7/8] feat: ui.table functionality (#145) This is not ready for review but I am putting this up just in case as I know Bender mentioned possibly working on some of the js functionality while I am on vacation. What's implemented is not thoroughly tested, needs to be refactored, and subject to change but I have sorts, quick filters, and the search bar hooked up. Example: ``` from deephaven import SortDirection import deephaven.ui as ui import deephaven.plot.express as dx stocks = dx.data.stocks() @ui.component def searchable_filtered_sorted_table(source): return [ ui.panel( ui.table(source) .sort("price", SortDirection.DESCENDING) .quick_filter({"side": "sell"}) .can_search("SHOW"), title="Stock Table I", ) ] sti = searchable_filtered_sorted_table(stocks) ``` --- plugins/ui/DESIGN.md | 14 +- .../ui/src/deephaven/ui/elements/UITable.py | 458 +++++++++++++++++- plugins/ui/src/deephaven/ui/types/types.py | 46 +- plugins/ui/src/js/src/UITable.tsx | 58 ++- plugins/ui/src/js/src/UITableUtils.tsx | 5 + plugins/ui/test/deephaven/ui/test_ui_table.py | 187 +++++++ 6 files changed, 749 insertions(+), 19 deletions(-) diff --git a/plugins/ui/DESIGN.md b/plugins/ui/DESIGN.md index ffd61aac5..c01ea5486 100644 --- a/plugins/ui/DESIGN.md +++ b/plugins/ui/DESIGN.md @@ -1388,22 +1388,24 @@ ui_table.selection_mode(mode: SelectionMode) -> UITable ##### sort Provide the default sort that will be used by the UI. +Can use Deephaven [SortDirection](https://deephaven.io/core/pydoc/code/deephaven.html#deephaven.SortDirection) used in +a table [sort](https://deephaven.io/core/docs/reference/table-operations/sort/) operation or`"ASC"` or `"DESC"`. ###### Syntax ```py ui_table.sort( order_by: str | Sequence[str], - order: SortDirection | Sequence[SortDirection] | None = None + order: TableSortDirection | Sequence[TableSortDirection] | None = None ) -> UITable ``` ###### Parameters -| Parameter | Type | Description | -| ----------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `by` | `str \| Sequence[str]` | The column(s) to sort by. May be a single column name, or a list of column names. | -| `direction` | `SortDirection \| Sequence[SortDirection] \| None` | The sort direction(s) to use. If provided, that must match up with the columns provided. Defaults to "ASC". | +| Parameter | Type | Description | +| ----------- |--------------------------------------------------------------| ----------------------------------------------------------------------------------------------------------- | +| `by` | `str \| Sequence[str]` | The column(s) to sort by. May be a single column name, or a list of column names. | +| `direction` | `TableSortDirection \| Sequence[TableSortDirection] \| None` | The sort direction(s) to use. If provided, that must match up with the columns provided. Defaults to "ASC". | #### ui.fragment @@ -1605,7 +1607,7 @@ RowIndex = int | None SearchMode = Literal["SHOW", "HIDE", "DEFAULT"] SelectionMode = Literal["CELL", "ROW", "COLUMN"] Sentinel = Any -SortDirection = Literal["ASC", "DESC"] +TableSortDirection = Union[Literal["ASC", "DESC"], SortDirection] TableData = dict[ColumnName, ColumnData] TransformedData = Any diff --git a/plugins/ui/src/deephaven/ui/elements/UITable.py b/plugins/ui/src/deephaven/ui/elements/UITable.py index adc606ee9..6ec053980 100644 --- a/plugins/ui/src/deephaven/ui/elements/UITable.py +++ b/plugins/ui/src/deephaven/ui/elements/UITable.py @@ -1,15 +1,51 @@ from __future__ import annotations import logging -from typing import Any, Callable, Dict +from typing import Any, Callable, Literal, Sequence from deephaven.table import Table +from deephaven import SortDirection from .Element import Element +from ..types import ( + ColumnName, + AggregationOperation, + SearchMode, + QuickFilterExpression, + Color, + ContextMenuAction, + CellIndex, + RowData, + ContextMenuMode, + DataBarAxis, + DataBarValuePlacement, + DataBarDirection, + RowIndex, + RowDataMap, + SelectionMode, + TableSortDirection, +) from .._internal import dict_to_camel_case, RenderContext logger = logging.getLogger(__name__) -RowIndex = int -RowDataMap = Dict[str, Any] + +def remap_sort_direction(direction: TableSortDirection) -> Literal["ASC", "DESC"]: + """ + Remap the sort direction to the grid sort direction + + Args: + direction: TableSortDirection: The deephaven sort direction or + grid sort direction to remap + + Returns: + Literal["ASC", "DESC"]: The grid sort direction + """ + if direction == SortDirection.ASCENDING: + return "ASC" + elif direction == SortDirection.DESCENDING: + return "DESC" + elif direction in {"ASC", "DESC"}: + return direction + raise ValueError(f"Invalid table sort direction: {direction}") class UITable(Element): @@ -37,24 +73,436 @@ def __init__(self, table: Table, props: dict[str, Any] = {}): self._table = table # Store the extra props that are added by each method - self._props = props + # This is a shallow copy of the props so that we don't mutate the passed in props dict + self._props = {**props} @property def name(self): return "deephaven.ui.elements.UITable" def _with_prop(self, key: str, value: Any) -> "UITable": + """ + Create a new UITable with the passed in prop added to the existing props + + Args: + key: str: The key to add to the props + value: Any: The value to add with the associated key + + Returns: + UITable: A new UITable with the passed in prop added to the existing props + """ logger.debug("_with_prop(%s, %s)", key, value) return UITable(self._table, {**self._props, key: value}) + def _with_appendable_prop(self, key: str, value: Any) -> "UITable": + """ + Create a new UITable with the passed in prop added to the existing prop + list (if it exists) or a new list with the passed in value + + Args: + key: str: The key to add to the props + value: Any: The value to add with the associated key + + Returns: + UITable: A new UITable with the passed in prop added to the existing props + """ + logger.debug("_with_appendable_prop(%s, %s)", key, value) + existing = self._props.get(key, []) + + if not isinstance(existing, list): + raise ValueError(f"Expected {key} to be a list") + + value = value if isinstance(value, list) else [value] + + return UITable(self._table, {**self._props, key: existing + value}) + + def _with_dict_prop(self, prop_name: str, value: dict) -> "UITable": + """ + Create a new UITable with the passed in prop in a dictionary. + This will override any existing prop with the same key within + the dict stored at prop_name. + + + Args: + prop_name: str: The key to add to the props + value: Any: The value to add with the associated key + + Returns: + UITable: A new UITable with the passed in prop added to the existing props + """ + logger.debug("_with_dict_prop(%s, %s)", prop_name, value) + existing = self._props.get(prop_name, {}) + new = {**existing, **value} + return UITable(self._table, {**self._props, prop_name: new}) + def render(self, context: RenderContext) -> dict[str, Any]: logger.debug("Returning props %s", self._props) return dict_to_camel_case({**self._props, "table": self._table}) + def aggregations( + self, + operations: dict[ColumnName, list[AggregationOperation]], + operation_order: list[AggregationOperation] | None = None, + default_operation: AggregationOperation = "Skip", + group_by: list[ColumnName] | None = None, + show_on_top: bool = False, + ) -> "UITable": + """ + Set the totals table to display below the main table. + + Args: + operations: dict[ColumnName, list[AggregationOperation]]: + The operations to apply to the columns of the table. + operation_order: list[AggregationOperation] | None: + The order in which to display the operations. + default_operation: AggregationOperation: + The default operation to apply to columns that do not have an operation specified. + group_by: list[ColumnName] | None: + The columns to group by. + show_on_top: bool: + Whether to show the totals table above the main table. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def always_fetch_columns(self, columns: str | list[str]) -> "UITable": + """ + Set the columns to always fetch from the server. + These will not be affected by the users current viewport/horizontal scrolling. + Useful if you have a column with key value data that you want to always include + in the data sent for row click operations. + + Args: + columns: str | list[str]: The columns to always fetch from the server. + May be a single column name. + + Returns: + UITable: A new UITable + """ + return self._with_appendable_prop("always_fetch_columns", columns) + + def back_columns(self, columns: str | list[str]) -> "UITable": + """ + Set the columns to show at the back of the table. + These will not be moveable in the UI. + + Args: + columns: str | list[str]: The columns to show at the back of the table. + May be a single column name. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def can_search(self, mode: SearchMode) -> "UITable": + """ + Set the search bar to explicitly be accessible or inaccessible, or use the system default. + + Args: + mode: SearchMode: Set the search bar to explicitly be accessible or inaccessible, + or use the system default. + + Returns: + UITable: A new UITable + """ + if mode == "SHOW": + return self._with_prop("can_search", True) + elif mode == "HIDE": + return self._with_prop("can_search", False) + elif mode == "DEFAULT": + new = self._with_prop("can_search", None) + # pop current can_search value if it exists + new._props.pop("can_search", None) + return new + + raise ValueError(f"Invalid search mode: {mode}") + + def column_group( + self, name: str, children: list[str], color: str | None + ) -> "UITable": + """ + Create a group for columns in the table. + + Args: + name: str: The group name. Must be a valid column name and not a duplicate of another column or group. + children: list[str]: The children in the group. May contain column names or other group names. + Each item may only be specified as a child once. + color: str | None: The hex color string or Deephaven color name. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def color_column( + self, + column: ColumnName, + where: QuickFilterExpression | None = None, + color: Color | None = None, + background_color: Color | None = None, + ) -> "UITable": + """ + Applies color formatting to a column of the table. + + Args: + column: ColumnName: The column name + where: QuickFilterExpression | None: The filter to apply to the expression. + Uses quick filter format (e.g. `>10`). + color: Color | None: The text color. Accepts hex color strings or Deephaven color names. + background_color: The background color. Accepts hex color strings or Deephaven color names. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def color_row( + self, + column: ColumnName, + where: QuickFilterExpression | None = None, + color: Color | None = None, + background_color: Color | None = None, + ) -> "UITable": + """ + Applies color formatting to rows of the table conditionally based on the value of a column. + + Args: + column: ColumnName: The column name + where: QuickFilterExpression | None: The filter to apply to the expression. + Uses quick filter format (e.g. `>10`). + color: Color | None: The text color. Accepts hex color strings or Deephaven color names. + background_color: The background color. Accepts hex color strings or Deephaven color names. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def context_menu( + self, + items: ContextMenuAction + | list[ContextMenuAction] + | Callable[[CellIndex, RowData], ContextMenuAction | list[ContextMenuAction]], + mode: ContextMenuMode = "CELL", + ) -> "UITable": + """ + Add custom items to the context menu. + You can provide a list of actions that always appear, + or a callback that can process the selection and send back menu items asynchronously. + You can also specify whether you want the menu items provided for a cell context menu, + a header context menu, or some combination of those. + You can also chain multiple sets of menu items by calling `.context_menu` multiple times. + + Args: + items: ContextMenuAction | list[ContextMenuAction] | + Callable[[CellIndex, RowData], ContextMenuAction | list[ContextMenuAction]]: + The items to add to the context menu. + May be a single `ContextMenuAction`, a list of `ContextMenuAction` objects, + or a callback function that takes the cell index and row data and returns either a single + `ContextMenuAction` or a list of `ContextMenuAction` objects. + mode: ContextMenuMode: Which specific context menu(s) to add the menu item(s) to. + Can be one or more modes. + Using `None` will add menu items in all cases. + `CELL`: Triggered from a cell. + `ROW_HEADER`: Triggered from a row header. + `COLUMN_HEADER`: Triggered from a column header. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def data_bar( + self, + col: str, + value_col: str = None, + min: float | str = None, + max: float | str = None, + axis: DataBarAxis | None = None, + positive_color: Color | list[Color] = None, + negative_color: Color | list[Color] = None, + value_placement: DataBarValuePlacement | None = None, + direction: DataBarDirection | None = None, + opacity: float = None, + marker_col: str = None, + marker_color: Color = None, + ) -> "UITable": + """ + Applies data bar formatting to the specified column. + + Args: + col: str: Column to generate data bars in + value_col: str: Column containing the values to generate data bars from + min: float | str: Minimum value for data bar scaling or column to get value from + max: float | str: Maximum value for data bar scaling or column to get value from + axis: DataBarAxis | None: Orientation of data bar relative to cell + positive_color: Color | list[Color]: Color for positive bars. Use list of colors to form a gradient + negative_color: Color | list[Color]: Color for negative bars. Use list of colors to form a gradient + value_placement: DataBarValuePlacement | None: Orientation of values relative to data bar + direction: DataBarDirection | None: Orientation of data bar relative to horizontal axis + opacity: float: Opacity of data bar. Accepts values from 0 to 1 + marker_col: str: Column containing the values to generate markers from + marker_color: Color: Color for markers + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def format(self, column: ColumnName, format: str) -> "UITable": + """ + Specify the formatting to display a column in. + + Args: + column: str: The column name + format: str: The format to display the column in. Valid format depends on column type + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def freeze_columns(self, columns: str | list[str]) -> "UITable": + """ + Set the columns to freeze to the front of the table. + These will always be visible and not affected by horizontal scrolling. + + Args: + columns: str | list[str]: The columns to freeze to the front of the table. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def front_columns(self, columns: str | list[str]) -> "UITable": + """ + Set the columns to show at the front of the table. These will not be moveable in the UI. + + Args: + columns: str | list[str]: The columns to show at the front of the table. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def hide_columns(self, columns: str | list[str]) -> "UITable": + """ + Set the columns to hide by default in the table. The user can still resize the columns to view them. + + Args: + columns: str | list[str]: The columns to hide from the table. May be a single column name. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def on_row_press( + self, callback: Callable[[RowIndex, RowDataMap], None] + ) -> "UITable": + """ + Add a callback for when a press on a row is released (e.g. a row is clicked). + + Args: + callback: Callable[[RowIndex, RowDataMap], None]: The callback function to run when a row is clicked. + The first parameter is the row index, and the second is the row data provided in a dictionary where the + column names are the keys. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + def on_row_double_press( self, callback: Callable[[RowIndex, RowDataMap], None] ) -> "UITable": """ - Add a callback to be invoked when a row is double-clicked. + Add a callback for when a row is double clicked. + + Args: + callback: Callable[[RowIndex, RowDataMap], None]: The callback function to run when a row is double clicked. + The first parameter is the row index, and the second is the row data provided in a dictionary where the + column names are the keys. + + Returns: + UITable: A new UITable """ return self._with_prop("on_row_double_press", callback) + + def quick_filter( + self, filter: dict[ColumnName, QuickFilterExpression] + ) -> "UITable": + """ + Add a quick filter for the UI to apply to the table. + + Args: + filter: dict[ColumnName, QuickFilterExpression]: The quick filter to apply to the table. + + Returns: + UITable: A new UITable + """ + return self._with_dict_prop("filters", filter) + + def selection_mode(self, mode: SelectionMode) -> "UITable": + """ + Set the selection mode for the table. + + Args: + mode: SelectionMode: The selection mode to use. Must be one of `"ROW"`, `"COLUMN"`, or `"CELL"` + `"ROW"` selects the entire row of the cell you click on. + `"COLUMN"` selects the entire column of the cell you click on. + `"CELL"` selects only the cells you click on. + + Returns: + UITable: A new UITable + """ + raise NotImplementedError() + + def sort( + self, + by: str | Sequence[str], + direction: TableSortDirection | Sequence[TableSortDirection] | None = None, + ) -> "UITable": + """ + Provide the default sort that will be used by the UI. + + Args: + by: The column(s) to sort by. May be a single column name, or a list of column names. + direction: The sort direction(s) to use. If provided, that must match up with the columns provided. + May be a single sort direction, or a list of sort directions. The possible sort directions are + `"ASC"` `"DESC"`, `SortDirection.ASCENDING`, and `SortDirection.DESCENDING`. + Defaults to "ASC". + + Returns: + UITable: A new UITable + """ + direction_list: Sequence[TableSortDirection] = [] + if direction: + direction_list = direction if isinstance(direction, list) else [direction] + # map deephaven sort direction to frontend sort direction + direction_list = [ + remap_sort_direction(direction) for direction in direction_list + ] + + by_list = by if isinstance(by, list) else [by] + + if direction and len(direction_list) != len(by_list): + raise ValueError("by and direction must be the same length") + + if direction: + sorts = [ + {"column": column, "direction": direction, "is_abs": False} + for column, direction in zip(by_list, direction_list) + ] + else: + sorts = [ + {"column": column, "direction": "ASC", "is_abs": False} + for column in by_list + ] + + return self._with_prop("sorts", sorts) diff --git a/plugins/ui/src/deephaven/ui/types/types.py b/plugins/ui/src/deephaven/ui/types/types.py index d7e26fec4..d42579da9 100644 --- a/plugins/ui/src/deephaven/ui/types/types.py +++ b/plugins/ui/src/deephaven/ui/types/types.py @@ -1,9 +1,47 @@ -from typing import Any, List, Dict, Literal +from typing import Any, Dict, Literal, Union, List +from deephaven import SortDirection -Sentinel = Any +RowIndex = Union[int, None] +RowDataMap = Dict[str, Any] +AggregationOperation = Literal[ + "COUNT", + "COUNT_DISTINCT", + "DISTINCT", + "MIN", + "MAX", + "SUM", + "ABS_SUM", + "VAR", + "AVG", + "STD", + "FIRST", + "LAST", + "UNIQUE", + "SKIP", +] +ColumnIndex = Union[int, None] +CellIndex = [RowIndex, ColumnIndex] +DeephavenColor = Literal["salmon", "lemonchiffon"] +HexColor = str +Color = Union[DeephavenColor, HexColor] +# A ColumnIndex of None indicates a header row ColumnName = str -ColumnData = List[Any] +ContextMenuAction = Dict[str, Any] +ContextMenuModeOption = Literal["CELL", "ROW_HEADER", "COLUMN_HEADER"] +ContextMenuMode = Union[ContextMenuModeOption, List[ContextMenuModeOption], None] +DataBarAxis = Literal["PROPORTIONAL", "MIDDLE", "DIRECTIONAL"] +DataBarDirection = Literal["LTR", "RTL"] +DataBarValuePlacement = Literal["BESIDE", "OVERLAP", "HIDE"] +# TODO: Fill in the list of Deephaven Colors we allow +LockType = Literal["shared", "exclusive"] +QuickFilterExpression = str RowData = Dict[ColumnName, Any] +# A RowIndex of None indicates a header column +RowIndex = Union[int, None] +ColumnData = List[Any] TableData = Dict[ColumnName, ColumnData] -LockType = Literal["shared", "exclusive"] +SearchMode = Literal["SHOW", "HIDE", "DEFAULT"] +SelectionMode = Literal["CELL", "ROW", "COLUMN"] +Sentinel = Any TransformedData = Any +TableSortDirection = Union[Literal["ASC", "DESC"], SortDirection] diff --git a/plugins/ui/src/js/src/UITable.tsx b/plugins/ui/src/js/src/UITable.tsx index fdbd852e7..75daf611d 100644 --- a/plugins/ui/src/js/src/UITable.tsx +++ b/plugins/ui/src/js/src/UITable.tsx @@ -1,9 +1,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import { + DehydratedQuickFilter, IrisGrid, IrisGridModel, IrisGridModelFactory, IrisGridProps, + IrisGridUtils, } from '@deephaven/iris-grid'; import { useApi } from '@deephaven/jsapi-bootstrap'; import type { Table } from '@deephaven/jsapi-types'; @@ -12,19 +14,55 @@ import { UITableProps } from './UITableUtils'; const log = Log.module('@deephaven/js-plugin-ui/UITable'); -function UITable({ onRowDoublePress, table: exportedTable }: UITableProps) { +function UITable({ + onRowDoublePress, + canSearch, + filters, + sorts, + alwaysFetchColumns, + table: exportedTable, +}: UITableProps) { const dh = useApi(); const [model, setModel] = useState(); + const [columns, setColumns] = useState(); + const utils = useMemo(() => new IrisGridUtils(dh), [dh]); + + const hydratedSorts = useMemo(() => { + if (sorts !== undefined && columns !== undefined) { + log.debug('Hydrating sorts', sorts); + + return utils.hydrateSort(columns, sorts); + } + return undefined; + }, [columns, utils, sorts]); + + const hydratedQuickFilters = useMemo(() => { + if (filters !== undefined && model !== undefined && columns !== undefined) { + log.debug('Hydrating filters', filters); + + const dehydratedQuickFilters: DehydratedQuickFilter[] = []; + + Object.entries(filters).forEach(([columnName, filter]) => { + const columnIndex = model.getColumnIndexByName(columnName); + if (columnIndex !== undefined) { + dehydratedQuickFilters.push([columnIndex, { text: filter }]); + } + }); + + return utils.hydrateQuickFilters(columns, dehydratedQuickFilters); + } + return undefined; + }, [filters, model, columns, utils]); // Just load the object on mount useEffect(() => { let isCancelled = false; async function loadModel() { - log.debug('Loading table from props', exportedTable); const reexportedTable = await exportedTable.reexport(); const newTable = (await reexportedTable.fetch()) as Table; const newModel = await IrisGridModelFactory.makeModel(dh, newTable); if (!isCancelled) { + setColumns(newTable.columns); setModel(newModel); } else { newModel.close(); @@ -37,8 +75,20 @@ function UITable({ onRowDoublePress, table: exportedTable }: UITableProps) { }, [dh, exportedTable]); const irisGridProps: Partial = useMemo( - () => ({ onDataSelected: onRowDoublePress }), - [onRowDoublePress] + () => ({ + onDataSelected: onRowDoublePress, + alwaysFetchColumns, + showSearchBar: canSearch, + sorts: hydratedSorts, + quickFilters: hydratedQuickFilters, + }), + [ + onRowDoublePress, + alwaysFetchColumns, + canSearch, + hydratedSorts, + hydratedQuickFilters, + ] ); // We want to clean up the model when we unmount or get a new model diff --git a/plugins/ui/src/js/src/UITableUtils.tsx b/plugins/ui/src/js/src/UITableUtils.tsx index 4cdb272ba..4bc6a2c11 100644 --- a/plugins/ui/src/js/src/UITableUtils.tsx +++ b/plugins/ui/src/js/src/UITableUtils.tsx @@ -1,4 +1,5 @@ import type { WidgetExportedObject } from '@deephaven/jsapi-types'; +import { DehydratedSort } from '@deephaven/iris-grid'; import { ELEMENT_KEY, ElementNode, isElementNode } from './ElementUtils'; export const UITABLE_ELEMENT_TYPE = 'deephaven.ui.elements.UITable'; @@ -11,6 +12,10 @@ export interface UITableProps { rowIndex: number, rowData: Record ) => void; + alwaysFetchColumns?: string[]; + canSearch?: boolean; + filters?: Record; + sorts?: DehydratedSort[]; [key: string]: unknown; } diff --git a/plugins/ui/test/deephaven/ui/test_ui_table.py b/plugins/ui/test/deephaven/ui/test_ui_table.py index 5cfe7e79a..338b710f1 100644 --- a/plugins/ui/test/deephaven/ui/test_ui_table.py +++ b/plugins/ui/test/deephaven/ui/test_ui_table.py @@ -40,3 +40,190 @@ def callback(row): "onRowDoublePress": callback, }, ) + + def test_always_fetch_columns(self): + import deephaven.ui as ui + + ui_table = ui.table(self.source) + + t = ui_table.always_fetch_columns("X") + + self.expect_render( + t, + { + "table": self.source, + "alwaysFetchColumns": ["X"], + }, + ) + + t = ui.table(self.source).always_fetch_columns(["X", "Y"]) + + self.expect_render( + t, + { + "table": self.source, + "alwaysFetchColumns": ["X", "Y"], + }, + ) + + t = ui.table(self.source).always_fetch_columns("X").always_fetch_columns("Y") + + self.expect_render( + t, + { + "table": self.source, + "alwaysFetchColumns": ["X", "Y"], + }, + ) + + def test_can_search(self): + import deephaven.ui as ui + + ui_table = ui.table(self.source) + + t = ui_table.can_search("SHOW") + + self.expect_render( + t, + { + "table": self.source, + "canSearch": True, + }, + ) + + t = ui_table.can_search("HIDE") + + self.expect_render( + t, + { + "table": self.source, + "canSearch": False, + }, + ) + + t = ui_table.can_search("DEFAULT") + + self.expect_render( + t, + { + "table": self.source, + }, + ) + + t = ui_table.can_search("SHOW").can_search("DEFAULT") + + self.expect_render( + t, + { + "table": self.source, + }, + ) + + t = ui_table.can_search("HIDE").can_search("DEFAULT") + + self.expect_render( + t, + { + "table": self.source, + }, + ) + + def test_quick_filter(self): + import deephaven.ui as ui + + ui_table = ui.table(self.source) + + t = ui_table.quick_filter({"X": "X > 1"}) + + self.expect_render( + t, + { + "table": self.source, + "filters": {"X": "X > 1"}, + }, + ) + + t = ui_table.quick_filter({"X": "X > 1"}).quick_filter({"X": "X > 2"}) + + self.expect_render( + t, + { + "table": self.source, + "filters": {"X": "X > 2"}, + }, + ) + + t = ui_table.quick_filter({"X": "X > 1", "Y": "Y < 2"}) + + self.expect_render( + t, + { + "table": self.source, + "filters": {"X": "X > 1", "Y": "Y < 2"}, + }, + ) + + def test_sort(self): + import deephaven.ui as ui + from deephaven import SortDirection + + ui_table = ui.table(self.source) + + t = ui_table.sort("X") + self.expect_render( + t, + { + "table": self.source, + "sorts": [{"column": "X", "direction": "ASC", "is_abs": False}], + }, + ) + + t = ui_table.sort("X", SortDirection.DESCENDING) + self.expect_render( + t, + { + "table": self.source, + "sorts": [{"column": "X", "direction": "DESC", "is_abs": False}], + }, + ) + + self.assertRaises( + ValueError, ui_table.sort, ["X", "Y"], [SortDirection.ASCENDING] + ) + + self.assertRaises( + ValueError, + ui_table.sort, + ["X"], + [SortDirection.ASCENDING, SortDirection.DESCENDING], + ) + + t = ui_table.sort( + ["X", "Y"], [SortDirection.ASCENDING, SortDirection.DESCENDING] + ) + + self.expect_render( + t, + { + "table": self.source, + "sorts": [ + {"column": "X", "direction": "ASC", "is_abs": False}, + {"column": "Y", "direction": "DESC", "is_abs": False}, + ], + }, + ) + + t = ui_table.sort(["X", "Y"], ["DESC", "ASC"]) + + self.expect_render( + t, + { + "table": self.source, + "sorts": [ + {"column": "X", "direction": "DESC", "is_abs": False}, + {"column": "Y", "direction": "ASC", "is_abs": False}, + ], + }, + ) + + self.assertRaises(ValueError, ui_table.sort, ["X", "Y"], ["INVALID"]) From 3208d1954cf463814c198548ea569fde6a187c2a Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Fri, 5 Jan 2024 16:30:23 -0500 Subject: [PATCH 8/8] fix: Don't use the API in the DashboardPlugin (#190) - This is a bigtime pitfall for Enterprise vs. Core plugins. Need to have this as a warning, as it's impossible for Enterprise to get the Community API in time to wrap the dashboard plugins... - Ticket https://deephaven.atlassian.net/browse/DH-16228 for tracking loading the API better on Enterprise side --- plugins/ui/src/js/src/WidgetHandler.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/ui/src/js/src/WidgetHandler.tsx b/plugins/ui/src/js/src/WidgetHandler.tsx index 72c42a0ec..6c94fd47b 100644 --- a/plugins/ui/src/js/src/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/WidgetHandler.tsx @@ -14,7 +14,6 @@ import { JSONRPCServer, JSONRPCServerAndClient, } from 'json-rpc-2.0'; -import { useApi } from '@deephaven/jsapi-bootstrap'; import type { Widget, WidgetExportedObject } from '@deephaven/jsapi-types'; import Log from '@deephaven/log'; import { @@ -39,8 +38,6 @@ export interface WidgetHandlerProps { } function WidgetHandler({ onClose, widget: wrapper }: WidgetHandlerProps) { - const dh = useApi(); - const [widget, setWidget] = useState(); const [document, setDocument] = useState(); @@ -186,7 +183,10 @@ function WidgetHandler({ onClose, widget: wrapper }: WidgetHandlerProps) { } const cleanup = widget.addEventListener( - dh.Widget.EVENT_MESSAGE, + // This is defined as dh.Widget.EVENT_MESSAGE in Core, but that constant doesn't exist on the Enterprise API + // Dashboard plugins in Enterprise are loaded with the Enterprise API in the context of the dashboard, so trying to fetch the constant fails + // Just use the constant value here instead. Another option would be to add the Widget constants to Enterprise, but we don't want to port over all that functionality. + 'message', (event: WidgetMessageEvent) => { receiveData( event.detail.getDataAsString(), @@ -209,7 +209,7 @@ function WidgetHandler({ onClose, widget: wrapper }: WidgetHandlerProps) { exportedObject.close(); }); }; - }, [dh, jsonClient, parseDocument, updateExportedObjects, widget]); + }, [jsonClient, parseDocument, updateExportedObjects, widget]); useEffect( function loadWidget() {