From b0bd63e0059c7ac25a008e43dc4d2f640c2f215f Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Fri, 26 Sep 2025 00:15:02 -0400 Subject: [PATCH 1/4] Virtual Server API Signed-off-by: Andrew Stein # Conflicts: # rust/perspective-python/Cargo.toml # rust/perspective-server/Cargo.toml # Conflicts: # rust/perspective-python/Cargo.toml # rust/perspective-server/Cargo.toml # Conflicts: # rust/perspective-server/Cargo.toml --- Cargo.lock | 27 +- rust/perspective-client/Cargo.toml | 3 +- rust/perspective-client/src/rust/lib.rs | 14 +- .../src/rust/virtual_server/data.rs | 233 ++++++++ .../src/rust/virtual_server/error.rs | 58 ++ .../src/rust/virtual_server/features.rs | 109 ++++ .../src/rust/virtual_server/handler.rs | 155 +++++ .../src/rust/virtual_server/mod.rs | 28 + .../src/rust/virtual_server/server.rs | 330 +++++++++++ rust/perspective-python/Cargo.toml | 4 + .../perspective/__init__.py | 2 + .../perspective/virtual_servers/__init__.py | 137 +++++ rust/perspective-python/src/lib.rs | 1 + rust/perspective-python/src/server/mod.rs | 1 + .../src/server/virtual_server_sync.rs | 549 ++++++++++++++++++ rust/perspective-server/Cargo.toml | 19 +- rust/perspective/Cargo.toml | 9 + rust/perspective/src/lib.rs | 2 + rust/perspective/src/virtual_server.rs | 80 +++ 19 files changed, 1754 insertions(+), 7 deletions(-) create mode 100644 rust/perspective-client/src/rust/virtual_server/data.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/error.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/features.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/handler.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/mod.rs create mode 100644 rust/perspective-client/src/rust/virtual_server/server.rs create mode 100644 rust/perspective-python/perspective/virtual_servers/__init__.py create mode 100644 rust/perspective-python/src/server/virtual_server_sync.rs create mode 100644 rust/perspective/src/virtual_server.rs diff --git a/Cargo.lock b/Cargo.lock index 6654e0161b..8e4e03acba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -681,6 +681,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fastrand" version = "2.3.0" @@ -873,7 +879,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22030e2c5a68ec659fde1e949a745124b48e6fa8b045b7ed5bd1fe4ccc5c4e5d" dependencies = [ - "fallible-iterator", + "fallible-iterator 0.2.0", "indexmap 1.9.3", "stable_deref_trait", ] @@ -1729,9 +1735,14 @@ version = "4.0.1" dependencies = [ "async-lock", "axum", + "fallible-iterator 0.3.0", "futures", + "indexmap 2.12.1", "perspective-client", "perspective-server", + "prost", + "serde", + "serde_json", "tokio", "tracing", ] @@ -1752,6 +1763,7 @@ dependencies = [ "async-lock", "futures", "getrandom 0.3.4", + "indexmap 2.12.1", "itertools 0.10.5", "num-traits", "paste", @@ -1810,9 +1822,12 @@ name = "perspective-python" version = "4.0.1" dependencies = [ "async-lock", + "bytes", + "chrono", "cmake", "extend", "futures", + "indexmap 2.12.1", "macro_rules_attribute", "num_cpus", "perspective-client", @@ -1823,6 +1838,7 @@ dependencies = [ "pyo3-build-config 0.22.6", "python-config-rs", "pythonize", + "serde", "tokio", "tracing", "tracing-subscriber", @@ -1835,11 +1851,16 @@ dependencies = [ "async-lock", "cmake", "futures", + "indexmap 2.12.1", "link-cplusplus", "num_cpus", "perspective-client", + "prost", "protobuf-src", + "serde", + "serde_json", "shlex", + "thiserror 1.0.69", "tracing", ] @@ -2107,9 +2128,9 @@ dependencies = [ [[package]] name = "protobuf-src" -version = "2.0.1+26.1" +version = "2.1.1+27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ba1cfa4b9dc098926b8cce388bf434b93516db3ecf6e8b1a37eb643d733ee7" +checksum = "6217c3504da19b85a3a4b2e9a5183d635822d83507ba0986624b5c05b83bfc40" dependencies = [ "cmake", ] diff --git a/rust/perspective-client/Cargo.toml b/rust/perspective-client/Cargo.toml index 7600933cef..980961cb0b 100644 --- a/rust/perspective-client/Cargo.toml +++ b/rust/perspective-client/Cargo.toml @@ -57,11 +57,12 @@ path = "src/rust/lib.rs" [build-dependencies] prost-build = { version = "0.12.3" } # https://github.com/abseil/abseil-cpp/issues/1241#issuecomment-2138616329 -protobuf-src = { version = "=2.0.1", optional = true } +protobuf-src = { version = "=2.1.1", optional = true } [dependencies] async-lock = { version = "2.5.0" } futures = { version = "0.3.28" } +indexmap = { version = "2.2.6", features = ["serde"] } itertools = { version = "0.10.1" } paste = { version = "1.0.12" } prost-types = { version = "0.12.3" } diff --git a/rust/perspective-client/src/rust/lib.rs b/rust/perspective-client/src/rust/lib.rs index 4fdc1ee333..2a7784a654 100644 --- a/rust/perspective-client/src/rust/lib.rs +++ b/rust/perspective-client/src/rust/lib.rs @@ -39,16 +39,18 @@ mod session; mod table; mod table_data; mod view; +pub mod virtual_server; pub mod config; #[rustfmt::skip] #[allow(clippy::all)] -mod proto; +pub mod proto; pub mod utils; pub use crate::client::{Client, ClientHandler, Features, ReconnectCallback, SystemInfo}; +use crate::proto::HostedTable; pub use crate::session::{ProxySession, Session}; pub use crate::table::{ DeleteOptions, ExprValidationResult, Table, TableInitOptions, TableReadFormat, UpdateOptions, @@ -66,6 +68,16 @@ pub mod vendor { pub use paste; } +impl From<&str> for HostedTable { + fn from(entity_id: &str) -> Self { + HostedTable { + entity_id: entity_id.to_string(), + index: None, + limit: None, + } + } +} + /// Assert that an implementation of domain language wrapper for [`Table`] /// implements the expected API. As domain languages have different API needs, /// a trait isn't useful for asserting that the entire API is implemented, diff --git a/rust/perspective-client/src/rust/virtual_server/data.rs b/rust/perspective-client/src/rust/virtual_server/data.rs new file mode 100644 index 0000000000..5a7cbf4beb --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/data.rs @@ -0,0 +1,233 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::error::Error; +use std::ops::{Deref, DerefMut}; + +use indexmap::IndexMap; +use serde::Serialize; + +use crate::config::Scalar; + +/// A column of data returned from a virtual server query. +/// +/// Each variant represents a different column type, containing a vector +/// of optional values. `None` values represent null/missing data. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum VirtualDataColumn { + Boolean(Vec>), + String(Vec>), + Float(Vec>), + Integer(Vec>), + Datetime(Vec>), + IntegerIndex(Vec>>), + RowPath(Vec>), +} + +/// A single cell value in a row-oriented data representation. +/// +/// Used when converting [`VirtualDataSlice`] to row format for JSON +/// serialization. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum VirtualDataCell { + Boolean(Option), + String(Option), + Float(Option), + Integer(Option), + Datetime(Option), + IntegerIndex(Option>), + RowPath(Vec), +} + +impl VirtualDataColumn { + /// Returns `true` if the column contains no elements. + pub fn is_empty(&self) -> bool { + match self { + VirtualDataColumn::Boolean(v) => v.is_empty(), + VirtualDataColumn::String(v) => v.is_empty(), + VirtualDataColumn::Float(v) => v.is_empty(), + VirtualDataColumn::Integer(v) => v.is_empty(), + VirtualDataColumn::Datetime(v) => v.is_empty(), + VirtualDataColumn::IntegerIndex(v) => v.is_empty(), + VirtualDataColumn::RowPath(v) => v.is_empty(), + } + } + + /// Returns the number of elements in the column. + pub fn len(&self) -> usize { + match self { + VirtualDataColumn::Boolean(v) => v.len(), + VirtualDataColumn::String(v) => v.len(), + VirtualDataColumn::Float(v) => v.len(), + VirtualDataColumn::Integer(v) => v.len(), + VirtualDataColumn::Datetime(v) => v.len(), + VirtualDataColumn::IntegerIndex(v) => v.len(), + VirtualDataColumn::RowPath(v) => v.len(), + } + } +} + +/// Trait for types that can be written to a [`VirtualDataColumn`] which +/// enforces sequential construction. +/// +/// This trait enables type-safe insertion of values into virtual data columns, +/// ensuring that values are written to columns of the correct type. +pub trait SetVirtualDataColumn { + /// Writes this value (sequentially) to the given column. + /// + /// Returns an error if the column type does not match the value type. + fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str>; + + /// Creates a new empty column of the appropriate type for this value. + fn new_column() -> VirtualDataColumn; + + /// Converts this value to a [`Scalar`] representation. + fn to_scalar(self) -> Scalar; +} + +macro_rules! template_psp { + ($t:ty, $u:ident, $v:ident, $w:ty) => { + impl SetVirtualDataColumn for Option<$t> { + fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str> { + if let VirtualDataColumn::$u(x) = col { + x.push(self); + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_column() -> VirtualDataColumn { + VirtualDataColumn::$u(vec![]) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::$v(x as $w) + } else { + Scalar::Null + } + } + } + }; +} + +template_psp!(String, String, String, String); +template_psp!(f64, Float, Float, f64); +template_psp!(i32, Integer, Float, f64); +template_psp!(i64, Datetime, Float, f64); +template_psp!(bool, Boolean, Bool, bool); + +/// A columnar data slice returned from a virtual server view query. +/// +/// This struct represents a rectangular slice of data from a view. It can be +/// serialized to JSON in either column-oriented or row-oriented format. +#[derive(Debug, Default, Serialize)] +pub struct VirtualDataSlice(IndexMap); + +impl Deref for VirtualDataSlice { + type Target = IndexMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for VirtualDataSlice { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl VirtualDataSlice { + pub(super) fn to_rows(&self) -> Vec> { + let num_rows = self.values().next().map(|x| x.len()).unwrap_or(0); + (0..num_rows) + .map(|row_idx| { + self.iter() + .map(|(col_name, col_data)| { + let row_value = match col_data { + VirtualDataColumn::Boolean(v) => VirtualDataCell::Boolean(v[row_idx]), + VirtualDataColumn::String(v) => { + VirtualDataCell::String(v[row_idx].clone()) + }, + VirtualDataColumn::Float(v) => VirtualDataCell::Float(v[row_idx]), + VirtualDataColumn::Integer(v) => VirtualDataCell::Integer(v[row_idx]), + VirtualDataColumn::Datetime(v) => VirtualDataCell::Datetime(v[row_idx]), + VirtualDataColumn::IntegerIndex(v) => { + VirtualDataCell::IntegerIndex(v[row_idx].clone()) + }, + VirtualDataColumn::RowPath(v) => { + VirtualDataCell::RowPath(v[row_idx].clone()) + }, + }; + (col_name.clone(), row_value) + }) + .collect() + }) + .collect() + } + + /// Sets a value in a column at the specified row index. + /// + /// If `group_by_index` is `Some`, the value is added to the `__ROW_PATH__` + /// column as part of the row's group-by path. Otherwise, the value is + /// inserted into the named column. + /// + /// Creates the column if it does not already exist. + pub fn set_col( + &mut self, + name: &str, + group_by_index: Option, + index: usize, + value: T, + ) -> Result<(), Box> { + if group_by_index.is_some() { + if !self.contains_key("__ROW_PATH__") { + self.insert( + "__ROW_PATH__".to_owned(), + VirtualDataColumn::RowPath(vec![]), + ); + } + + let Some(VirtualDataColumn::RowPath(col)) = self.get_mut("__ROW_PATH__") else { + return Err("__ROW_PATH__ column has unexpected type".into()); + }; + + if let Some(row) = col.get_mut(index) { + let scalar = value.to_scalar(); + row.push(scalar); + } else { + while col.len() < index { + col.push(vec![]) + } + + let scalar = value.to_scalar(); + col.push(vec![scalar]); + } + + Ok(()) + } else { + if !self.contains_key(name) { + self.insert(name.to_owned(), T::new_column()); + } + + let col = self + .get_mut(name) + .ok_or_else(|| format!("Column '{}' not found after insertion", name))?; + + Ok(value.write_to(col)?) + } + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/error.rs b/rust/perspective-client/src/rust/virtual_server/error.rs new file mode 100644 index 0000000000..3f48f24467 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/error.rs @@ -0,0 +1,58 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use prost::{DecodeError, EncodeError}; +use thiserror::Error; + +/// Error type for virtual server operations. +/// +/// This enum represents the various errors that can occur when processing +/// requests through a [`VirtualServer`](super::VirtualServer). +#[derive(Clone, Error, Debug)] +pub enum VirtualServerError { + #[error("External Error: {0:?}")] + InternalError(#[from] T), + + #[error("{0}")] + DecodeError(DecodeError), + + #[error("{0}")] + EncodeError(EncodeError), + + #[error("Unknown view '{0}'")] + UnknownViewId(String), + + #[error("Invalid JSON'{0}'")] + InvalidJSON(std::sync::Arc), + + #[error("{0}")] + Other(String), +} + +/// Extension trait for extracting internal errors from [`VirtualServerError`] +/// results. +/// +/// Provides a method to distinguish between internal handler errors and other +/// virtual server errors. +pub trait ResultExt { + fn get_internal_error(self) -> Result>; +} + +impl ResultExt for Result> { + fn get_internal_error(self) -> Result> { + match self { + Ok(x) => Ok(x), + Err(VirtualServerError::InternalError(x)) => Err(Ok(x)), + Err(x) => Err(Err(x.to_string())), + } + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/features.rs b/rust/perspective-client/src/rust/virtual_server/features.rs new file mode 100644 index 0000000000..5766bf6630 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/features.rs @@ -0,0 +1,109 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::borrow::Cow; + +use indexmap::IndexMap; +use serde::Deserialize; + +use crate::proto::get_features_resp::{AggregateArgs, AggregateOptions, ColumnTypeOptions}; +use crate::proto::{ColumnType, GetFeaturesResp}; + +/// Describes the capabilities supported by a virtual server handler. +/// +/// This struct is returned by [`VirtualServerHandler::get_features`](super::VirtualServerHandler::get_features) +/// to inform clients about which operations are available. +#[derive(Debug, Default, Deserialize)] +pub struct Features<'a> { + /// Whether group-by aggregation is supported. + #[serde(default)] + pub group_by: bool, + + /// Whether split-by (pivot) operations are supported. + #[serde(default)] + pub split_by: bool, + + /// Available filter operators per column type. + #[serde(default)] + pub filter_ops: IndexMap>>, + + /// Available aggregate functions per column type. + #[serde(default)] + pub aggregates: IndexMap>>, + + /// Whether sorting is supported. + #[serde(default)] + pub sort: bool, + + /// Whether computed expressions are supported. + #[serde(default)] + pub expressions: bool, + + /// Whether update callbacks are supported. + #[serde(default)] + pub on_update: bool, +} + +/// Specification for an aggregate function. +/// +/// Aggregates can either take no additional arguments ([`AggSpec::Single`]) +/// or require column type arguments ([`AggSpec::Multiple`]). +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum AggSpec<'a> { + /// An aggregate function with no additional arguments. + Single(Cow<'a, str>), + /// An aggregate function that requires column type arguments. + Multiple(Cow<'a, str>, Vec), +} + +impl<'a> From> for GetFeaturesResp { + fn from(value: Features<'a>) -> GetFeaturesResp { + GetFeaturesResp { + group_by: value.group_by, + split_by: value.split_by, + expressions: value.expressions, + on_update: value.on_update, + sort: value.sort, + aggregates: value + .aggregates + .iter() + .map(|(dtype, aggs)| { + (*dtype as u32, AggregateOptions { + aggregates: aggs + .iter() + .map(|agg| match agg { + AggSpec::Single(cow) => AggregateArgs { + name: cow.to_string(), + args: vec![], + }, + AggSpec::Multiple(cow, column_types) => AggregateArgs { + name: cow.to_string(), + args: column_types.iter().map(|x| *x as i32).collect(), + }, + }) + .collect(), + }) + }) + .collect(), + filter_ops: value + .filter_ops + .iter() + .map(|(ty, options)| { + (*ty as u32, ColumnTypeOptions { + options: options.iter().map(|x| (*x).to_string()).collect(), + }) + }) + .collect(), + } + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/handler.rs b/rust/perspective-client/src/rust/virtual_server/handler.rs new file mode 100644 index 0000000000..fa30e85d4f --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/handler.rs @@ -0,0 +1,155 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::future::Future; +use std::pin::Pin; + +use indexmap::IndexMap; + +use super::data::VirtualDataSlice; +use super::features::Features; +use crate::config::{ViewConfig, ViewConfigUpdate}; +use crate::proto::{ColumnType, HostedTable, TableMakePortReq, ViewPort}; + +#[cfg(target_arch = "wasm32")] +pub type VirtualServerFuture<'a, T> = Pin + 'a>>; + +/// A boxed future that conditionally implements `Send` based on the target +/// architecture. +/// +/// This only compiles on wasm, except for `rust-analyzer` and `metadata` +/// generation, so this type exists to tryck the compiler +#[cfg(not(target_arch = "wasm32"))] +pub type VirtualServerFuture<'a, T> = Pin + Send + 'a>>; + +/// Handler trait for implementing virtual server backends. +/// +/// This trait defines the interface that must be implemented to provide +/// a custom data source for the Perspective virtual server. Implementors +/// handle table and view operations, translating them to their underlying +/// data store. +pub trait VirtualServerHandler { + // Required + + /// The error type returned by handler methods. + type Error: std::error::Error + Send + Sync + 'static; + + /// Returns a list of all tables hosted by this handler. + fn get_hosted_tables(&self) -> VirtualServerFuture<'_, Result, Self::Error>>; + + /// Returns the schema (column names and types) for a table. + fn table_schema( + &self, + table_id: &str, + ) -> VirtualServerFuture<'_, Result, Self::Error>>; + + /// Returns the number of rows in a table. + fn table_size(&self, table_id: &str) -> VirtualServerFuture<'_, Result>; + + /// Creates a new view on a table with the given configuration. + /// + /// The handler may modify the configuration to reflect any adjustments + /// made during view creation. + fn table_make_view( + &mut self, + view_id: &str, + view_id: &str, + config: &mut ViewConfigUpdate, + ) -> VirtualServerFuture<'_, Result>; + + /// Deletes a view and releases its resources. + fn view_delete(&self, view_id: &str) -> VirtualServerFuture<'_, Result<(), Self::Error>>; + + /// Retrieves data from a view within the specified viewport. + fn view_get_data( + &self, + view_id: &str, + config: &ViewConfig, + viewport: &ViewPort, + ) -> VirtualServerFuture<'_, Result>; + + // Optional + + /// Return the column count of a `Table` + fn table_column_size( + &self, + table_id: &str, + ) -> VirtualServerFuture<'_, Result> { + let fut = self.table_schema(table_id); + Box::pin(async move { Ok(fut.await?.len() as u32) }) + } + + /// Returns the number of rows in a `View`. + fn view_size(&self, view_id: &str) -> VirtualServerFuture<'_, Result> { + Box::pin(self.table_size(view_id)) + } + + /// Return the column count of a `View` + fn view_column_size( + &self, + view_id: &str, + config: &ViewConfig, + ) -> VirtualServerFuture<'_, Result> { + let fut = self.view_schema(view_id, config); + Box::pin(async move { Ok(fut.await?.len() as u32) }) + } + + /// Returns the schema of a view after applying its configuration. + fn view_schema( + &self, + view_id: &str, + _config: &ViewConfig, + ) -> VirtualServerFuture<'_, Result, Self::Error>> { + Box::pin(self.table_schema(view_id)) + } + + /// Validates an expression against a table and returns its result type. + /// + /// Default implementation returns `Float` for all expressions. + fn table_validate_expression( + &self, + _table_id: &str, + _expression: &str, + ) -> VirtualServerFuture<'_, Result> { + Box::pin(async { Ok(ColumnType::Float) }) + } + + /// Returns the features supported by this handler. + /// + /// Default implementation returns default features. + fn get_features(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { + Box::pin(async { Ok(Features::default()) }) + } + + /// Creates a new input port on a table. + /// + /// Default implementation returns port ID 0. + fn table_make_port( + &self, + _req: &TableMakePortReq, + ) -> VirtualServerFuture<'_, Result> { + Box::pin(async { Ok(0) }) + } + + // Unused + + /// Creates a new table with the given data. + /// + /// Default implementation panics with "not implemented". + fn make_table( + &mut self, + _table_id: &str, + _data: &crate::proto::MakeTableData, + ) -> VirtualServerFuture<'_, Result<(), Self::Error>> { + Box::pin(async { unimplemented!("make_table not implemented") }) + } +} diff --git a/rust/perspective-client/src/rust/virtual_server/mod.rs b/rust/perspective-client/src/rust/virtual_server/mod.rs new file mode 100644 index 0000000000..2082c2c123 --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/mod.rs @@ -0,0 +1,28 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +//! Virtual server implementation for Perspective. +//! +//! This module provides a virtual server that can process Perspective protocol +//! messages and delegate operations to a custom backend handler. + +mod data; +mod error; +mod features; +mod handler; +mod server; + +pub use data::{SetVirtualDataColumn, VirtualDataCell, VirtualDataColumn, VirtualDataSlice}; +pub use error::{ResultExt, VirtualServerError}; +pub use features::{AggSpec, Features}; +pub use handler::{VirtualServerFuture, VirtualServerHandler}; +pub use server::VirtualServer; diff --git a/rust/perspective-client/src/rust/virtual_server/server.rs b/rust/perspective-client/src/rust/virtual_server/server.rs new file mode 100644 index 0000000000..394c40335a --- /dev/null +++ b/rust/perspective-client/src/rust/virtual_server/server.rs @@ -0,0 +1,330 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::collections::HashMap; + +use indexmap::IndexMap; +use prost::Message as ProstMessage; +use prost::bytes::{Bytes, BytesMut}; + +use super::error::VirtualServerError; +use super::handler::VirtualServerHandler; +use crate::config::{ViewConfig, ViewConfigUpdate}; +use crate::proto::response::ClientResp; +use crate::proto::table_validate_expr_resp::ExprValidationError; +use crate::proto::{ + GetFeaturesResp, GetHostedTablesResp, MakeTableResp, Request, Response, TableMakePortResp, + TableMakeViewResp, TableOnDeleteResp, TableRemoveDeleteResp, TableSchemaResp, TableSizeResp, + TableValidateExprResp, ViewColumnPathsResp, ViewDeleteResp, ViewDimensionsResp, + ViewExpressionSchemaResp, ViewGetConfigResp, ViewOnDeleteResp, ViewOnUpdateResp, + ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, ViewSchemaResp, ViewToColumnsStringResp, + ViewToRowsStringResp, +}; + +macro_rules! respond { + ($msg:ident, $name:ident { $($rest:tt)* }) => {{ + let mut resp = BytesMut::new(); + let resp2 = ClientResp::$name($name { + $($rest)* + }); + + Response { + msg_id: $msg.msg_id, + entity_id: $msg.entity_id, + client_resp: Some(resp2), + }.encode(&mut resp).map_err(VirtualServerError::EncodeError)?; + + resp.freeze() + }}; +} + +/// A virtual server that processes Perspective protocol messages. +/// +/// `VirtualServer` acts as a bridge between the Perspective protocol and a +/// custom data backend. It handles protocol decoding/encoding and delegates +/// actual data operations to the provided [`VirtualServerHandler`]. +pub struct VirtualServer { + handler: T, + view_to_table: IndexMap, + view_configs: IndexMap, +} + +impl VirtualServer { + /// Creates a new virtual server with the given handler. + pub fn new(handler: T) -> Self { + Self { + handler, + view_configs: IndexMap::default(), + view_to_table: IndexMap::default(), + } + } + + /// Processes a Perspective protocol request and returns the response. + /// + /// Decodes the incoming protobuf message, dispatches to the appropriate + /// handler method, and encodes the response. + pub async fn handle_request( + &mut self, + bytes: Bytes, + ) -> Result> { + use crate::proto::request::ClientReq::*; + let msg = Request::decode(bytes).map_err(VirtualServerError::DecodeError)?; + tracing::debug!( + "Handling request: entity_id={}, req={:?}", + msg.entity_id, + msg.client_req + ); + + let resp = match msg.client_req.unwrap() { + GetFeaturesReq(_) => { + let features = self.handler.get_features().await?; + respond!(msg, GetFeaturesResp { ..features.into() }) + }, + GetHostedTablesReq(_) => { + respond!(msg, GetHostedTablesResp { + table_infos: self.handler.get_hosted_tables().await? + }) + }, + TableSchemaReq(_) => { + respond!(msg, TableSchemaResp { + schema: self + .handler + .table_schema(msg.entity_id.as_str()) + .await + .ok() + .map(|value| crate::proto::Schema { + schema: value + .iter() + .map(|x| crate::proto::schema::KeyTypePair { + name: x.0.to_string(), + r#type: *x.1 as i32, + }) + .collect(), + }) + }) + }, + TableMakePortReq(req) => { + respond!(msg, TableMakePortResp { + port_id: self.handler.table_make_port(&req).await? + }) + }, + TableMakeViewReq(req) => { + self.view_to_table + .insert(req.view_id.clone(), msg.entity_id.clone()); + + let mut config: ViewConfigUpdate = req.config.clone().unwrap_or_default().into(); + let bytes = respond!(msg, TableMakeViewResp { + view_id: self + .handler + .table_make_view(msg.entity_id.as_str(), req.view_id.as_str(), &mut config) + .await? + }); + + self.view_configs.insert(req.view_id.clone(), config.into()); + bytes + }, + TableSizeReq(_) => { + respond!(msg, TableSizeResp { + size: self.handler.table_size(msg.entity_id.as_str()).await? + }) + }, + TableValidateExprReq(req) => { + let mut expression_schema = HashMap::::default(); + let mut expression_alias = HashMap::::default(); + let mut errors = HashMap::::default(); + for (name, ex) in req.column_to_expr.iter() { + let _ = expression_alias.insert(name.clone(), ex.clone()); + match self + .handler + .table_validate_expression(&msg.entity_id, ex.as_str()) + .await + { + Ok(dtype) => { + let _ = expression_schema.insert(name.clone(), dtype as i32); + }, + Err(e) => { + let _ = errors.insert(name.clone(), ExprValidationError { + error_message: format!("{}", e), + line: 0, + column: 0, + }); + }, + } + } + + respond!(msg, TableValidateExprResp { + expression_schema, + errors, + expression_alias, + }) + }, + ViewSchemaReq(_) => { + respond!(msg, ViewSchemaResp { + schema: self + .handler + .view_schema( + msg.entity_id.as_str(), + self.view_configs.get(&msg.entity_id).unwrap() + ) + .await? + .into_iter() + .map(|(x, y)| (x, y as i32)) + .collect() + }) + }, + ViewDimensionsReq(_) => { + let view_id = &msg.entity_id; + let table_id = self + .view_to_table + .get(view_id) + .ok_or_else(|| VirtualServerError::UnknownViewId(view_id.to_string()))?; + + let num_table_rows = self.handler.table_size(table_id).await?; + let num_table_columns = self.handler.table_column_size(table_id).await? as u32; + let config = self.view_configs.get(view_id).unwrap(); + let num_view_columns = self.handler.view_column_size(view_id, config).await? as u32; + let num_view_rows = self.handler.view_size(view_id).await?; + let resp = ViewDimensionsResp { + num_table_columns, + num_table_rows, + num_view_columns, + num_view_rows, + }; + + respond!(msg, ViewDimensionsResp { ..resp }) + }, + ViewGetConfigReq(_) => { + respond!(msg, ViewGetConfigResp { + config: Some( + ViewConfigUpdate::from( + self.view_configs.get(&msg.entity_id).unwrap().clone() + ) + .into() + ) + }) + }, + ViewExpressionSchemaReq(_) => { + let mut schema = HashMap::::default(); + let table_id = self.view_to_table.get(&msg.entity_id); + for (name, ex) in self + .view_configs + .get(&msg.entity_id) + .unwrap() + .expressions + .iter() + { + match self + .handler + .table_validate_expression(table_id.unwrap(), ex.as_str()) + .await + { + Ok(dtype) => { + let _ = schema.insert(name.clone(), dtype as i32); + }, + Err(_e) => { + // TODO: handle error + }, + } + } + + let resp = ViewExpressionSchemaResp { schema }; + respond!(msg, ViewExpressionSchemaResp { ..resp }) + }, + ViewColumnPathsReq(_) => { + respond!(msg, ViewColumnPathsResp { + paths: self + .handler + .view_schema( + msg.entity_id.as_str(), + self.view_configs.get(&msg.entity_id).unwrap() + ) + .await? + .keys() + .cloned() + .collect() + }) + }, + ViewToRowsStringReq(view_to_rows_string_req) => { + let viewport = view_to_rows_string_req.viewport.unwrap(); + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &viewport) + .await?; + + let rows = cols.to_rows(); + let json_string = serde_json::to_string(&rows) + .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?; + + respond!(msg, ViewToRowsStringResp { json_string }) + }, + ViewToColumnsStringReq(view_to_columns_string_req) => { + let viewport = view_to_columns_string_req.viewport.unwrap(); + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &viewport) + .await?; + + let json_string = serde_json::to_string(&cols) + .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?; + + respond!(msg, ViewToColumnsStringResp { json_string }) + }, + ViewDeleteReq(_) => { + self.handler.view_delete(msg.entity_id.as_str()).await?; + self.view_to_table.shift_remove(&msg.entity_id); + self.view_configs.shift_remove(&msg.entity_id); + respond!(msg, ViewDeleteResp {}) + }, + MakeTableReq(req) => { + self.handler + .make_table(&msg.entity_id, req.data.as_ref().unwrap()) + .await?; + respond!(msg, MakeTableResp {}) + }, + + // Stub implementations for callback/update requests that VirtualServer doesn't support + TableOnDeleteReq(_) => { + respond!(msg, TableOnDeleteResp {}) + }, + ViewOnUpdateReq(_) => { + respond!(msg, ViewOnUpdateResp { + delta: None, + port_id: 0 + }) + }, + ViewOnDeleteReq(_) => { + respond!(msg, ViewOnDeleteResp {}) + }, + ViewRemoveOnUpdateReq(_) => { + respond!(msg, ViewRemoveOnUpdateResp {}) + }, + TableRemoveDeleteReq(_) => { + respond!(msg, TableRemoveDeleteResp {}) + }, + ViewRemoveDeleteReq(_) => { + respond!(msg, ViewRemoveDeleteResp {}) + }, + + x => { + // Return an error response instead of empty bytes + return Err(VirtualServerError::Other(format!( + "Unhandled request: {:?}", + x + ))); + }, + }; + + Ok(resp) + } +} diff --git a/rust/perspective-python/Cargo.toml b/rust/perspective-python/Cargo.toml index 86597605f2..8e9488ac6e 100644 --- a/rust/perspective-python/Cargo.toml +++ b/rust/perspective-python/Cargo.toml @@ -59,11 +59,15 @@ python-config-rs = "0.1.2" [dependencies] perspective-client = { version = "4.0.1" } perspective-server = { version = "4.0.1" } +bytes = "1.10.1" +chrono = "0.4" macro_rules_attribute = "0.2.0" async-lock = "2.5.0" pollster = "0.3.0" extend = "1.1.2" +indexmap = "2.2.6" futures = "0.3.28" +serde = { version = "1.0" } pyo3 = { version = "0.25.1", features = [ "experimental-async", "extension-module", diff --git a/rust/perspective-python/perspective/__init__.py b/rust/perspective-python/perspective/__init__.py index 5e60e089d7..6736bfa86e 100644 --- a/rust/perspective-python/perspective/__init__.py +++ b/rust/perspective-python/perspective/__init__.py @@ -21,6 +21,7 @@ "ProxySession", "AsyncClient", "AsyncServer", + "VirtualServer", "num_cpus", "set_num_cpus", "system_info", @@ -351,6 +352,7 @@ def delete_callback(): Server, AsyncServer, AsyncClient, + VirtualServer, # NOTE: these are classes without constructors, # so we import them just for type hinting Table, # noqa: F401 diff --git a/rust/perspective-python/perspective/virtual_servers/__init__.py b/rust/perspective-python/perspective/virtual_servers/__init__.py new file mode 100644 index 0000000000..faf9b4773b --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/__init__.py @@ -0,0 +1,137 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ Copyright (c) 2017, the Perspective Authors. ┃ +# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +# ┃ This file is part of the Perspective library, distributed under the terms ┃ +# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + +class VirtualSessionModel: + """ + An interface for implementing a Perspective `VirtualServer`. It operates + thusly: + + - A table is selected by name (validated via `get_hosted_tables`). + + - The UI will ask the model to create a temporary table with the results + of querying this table with a specific query `config`, a simple struct + which reflects the UI configurable fields (see `get_features`). + + - The UI will query slices of the temporary table as it needs them to + render. This may be a rectangular slice, a whole column or the entire + set, and it is returned from teh model via a custom push-only + struct `PerspectiveColumn` for now, though in the future we will support + e.g. Polars and other arrow-native formats directly. + + - The UI will delete its own temporary tables via `view_delete` but it is + ok for them to die intermittently, the UI will recover automatically. + """ + + def get_features(self): + """ + [OPTIONAL] Toggle UI features through data model support. For example, + setting `"group_by": False` would hide the "Group By" UI control, as + well as prevent this field from appearing in `config` dicts later + provided to `table_make_view`. + + This API defaults to just "columns", e.g. a simple flat datagrid in + which you can just scroll, select and format columns. + + # Example + + ```python + return { + "group_by": True, + "split_by": True, + "sort": True, + "expressions": True, + "filter_ops": { + "integer": ["==", "<"], + }, + "aggregates": { + "string": ["count"], + "float": ["count", "sum"], + }, + } + ``` + """ + + pass + + def get_hosted_tables(self) -> list[str]: + """ + List of `Table` names available to query from. + """ + + pass + + def table_schema(self, table_name): + """ + Get the _Perspective Schema_ for a `Table`, a mapping of column name to + Perspective column types, a simplified set of six visually-relevant + types mapped from DuckDB's much richer type system. Optionally, + a model may also implement `view_schema` which describes temporary + tables, but for DuckDB this method is identical. + """ + + pass + + def table_columns_size(self, table_name, config): + pass + + def table_size(self, table_name): + """ + Get a table's row count. Optionally, a model may also implement the + `view_size` method to get the row count for temporary tables, but for + DuckDB this method is identical. + """ + + pass + + def view_schema(self, view_name, config): + return self.table_schema(view_name) + + def view_size(self, view_name): + return self.table_size(view_name) + + def table_make_view(self, table_name, view_name, config): + """ + Create a temporary table `view_name` from the results of querying + `table_name` with a query configuration `config`. + """ + + pass + + def table_validate_expression(self, view_name, expression): + """ + [OPTIONAL] Given a temporary table `view_name`, validate the type of + a column expression string `expression`, or raise an error if the + expression is invalid. This is enabeld by `"expressions"` via + `get_features` and defaults to allow all expressions. + """ + + pass + + def view_delete(self, view_name): + """ + Delete a temporary table. The UI will do this automatically, and it + can recover. + """ + + pass + + def view_get_data(self, view_name, config, viewport, data): + """ + Serialize a rectangular slice `viewport` from temporary table + `view_name`, into the `PerspectiveColumn` serialization API injected + via `data`. The push-only `PerspectiveColumn` type can handle casting + Python types as input, but once a type is pushed to a column name it + must not be changed. + """ + + pass diff --git a/rust/perspective-python/src/lib.rs b/rust/perspective-python/src/lib.rs index e119f030a1..0b77950122 100644 --- a/rust/perspective-python/src/lib.rs +++ b/rust/perspective-python/src/lib.rs @@ -71,6 +71,7 @@ fn perspective(py: Python, m: &Bound) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add("PerspectiveError", py.get_type::())?; m.add_function(wrap_pyfunction!(num_cpus, m)?)?; m.add_function(wrap_pyfunction!(set_num_cpus, m)?)?; diff --git a/rust/perspective-python/src/server/mod.rs b/rust/perspective-python/src/server/mod.rs index 8f1e4e2475..6ae102bf99 100644 --- a/rust/perspective-python/src/server/mod.rs +++ b/rust/perspective-python/src/server/mod.rs @@ -14,6 +14,7 @@ mod server_async; mod server_sync; pub(crate) mod session_async; pub(crate) mod session_sync; +pub(crate) mod virtual_server_sync; pub use server_async::*; pub use server_sync::*; diff --git a/rust/perspective-python/src/server/virtual_server_sync.rs b/rust/perspective-python/src/server/virtual_server_sync.rs new file mode 100644 index 0000000000..86cbde1693 --- /dev/null +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -0,0 +1,549 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use chrono::{DateTime, TimeZone, Utc}; +use indexmap::IndexMap; +use perspective_client::proto::{ColumnType, HostedTable}; +use perspective_client::virtual_server::{ + Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerFuture, VirtualServerHandler, +}; +use pyo3::exceptions::PyValueError; +use pyo3::types::{ + PyAnyMethods, PyBytes, PyDate, PyDict, PyDictMethods, PyList, PyListMethods, PyString, +}; +use pyo3::{IntoPyObject, Py, PyAny, PyErr, PyResult, Python, pyclass, pymethods}; +use serde::Serialize; + +pub struct PyServerHandler(Py); + +impl VirtualServerHandler for PyServerHandler { + type Error = PyErr; + + fn get_features(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + Box::pin(async move { + Python::with_gil(|py| { + if handler + .getattr(py, pyo3::intern!(py, "get_features")) + .is_ok() + { + Ok(pythonize::depythonize( + handler.call_method0(py, "get_features")?.bind(py), + )?) + } else { + Ok(Features::default()) + } + }) + }) + } + + fn get_hosted_tables(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + Box::pin(async move { + Python::with_gil(|py| { + Ok(handler + .call_method0(py, pyo3::intern!(py, "get_hosted_tables"))? + .downcast_bound::(py)? + .iter() + .flat_map(|x| { + Ok::<_, PyErr>(if x.is_instance_of::() { + HostedTable { + entity_id: x.to_string(), + index: None, + limit: None, + } + } else { + HostedTable { + entity_id: x.get_item("name")?.to_string(), + index: x.get_item("index").ok().and_then(|x| x.extract().ok()), + limit: x.get_item("limit").ok().and_then(|x| x.extract().ok()), + } + }) + }) + .collect::>()) + }) + }) + } + + fn table_schema( + &self, + table_id: &str, + ) -> VirtualServerFuture<'_, Result, Self::Error>> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + Box::pin(async move { + Python::with_gil(|py| { + Ok(handler + .call_method1(py, pyo3::intern!(py, "table_schema"), (&table_id,))? + .downcast_bound::(py)? + .items() + .extract::>()? + .into_iter() + .map(|(k, v)| (k, ColumnType::from_str(&v).unwrap())) + .collect()) + }) + }) + } + + fn table_size(&self, table_id: &str) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + Box::pin(async move { + Python::with_gil(|py| { + handler + .call_method1(py, pyo3::intern!(py, "table_size"), (&table_id,))? + .extract::(py) + }) + }) + } + + fn table_column_size( + &self, + table_id: &str, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + + Box::pin(async move { + let has_table_column_size = + Python::with_gil(|py| handler.getattr(py, "table_column_size").is_ok()); + if has_table_column_size { + Python::with_gil(|py| { + handler + .call_method1(py, pyo3::intern!(py, "table_column_size"), (&table_id,))? + .extract::(py) + }) + } else { + Ok(self.table_schema(&table_id).await?.len() as u32) + } + }) + } + + fn table_validate_expression( + &self, + table_id: &str, + expression: &str, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + let expression = expression.to_string(); + Box::pin(async move { + Python::with_gil(|py| { + let name = pyo3::intern!(py, "table_validate_expression"); + if handler.getattr(py, name).is_ok() { + Ok(handler + .call_method1(py, name, (&table_id, &expression))? + .downcast_bound::(py)? + .extract::()?) + .map(|x| ColumnType::from_str(x.as_str()).unwrap()) + } else { + // TODO this should probably be an error. + Ok(ColumnType::Float) + } + }) + }) + } + + fn table_make_view( + &mut self, + table_id: &str, + view_id: &str, + config: &mut perspective_client::config::ViewConfigUpdate, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + let view_id = view_id.to_string(); + let config = config.clone(); + Box::pin(async move { + Python::with_gil(|py| { + let _ = handler + .call_method1( + py, + pyo3::intern!(py, "table_make_view"), + (&table_id, &view_id, pythonize::pythonize(py, &config)?), + )? + .extract::(py); + + Ok(view_id.to_string()) + }) + }) + } + + fn view_schema( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> VirtualServerFuture<'_, Result, Self::Error>> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let config = config.clone(); + Box::pin(async move { + Python::with_gil(|py| { + let has_view_schema = handler.getattr(py, "view_schema").is_ok(); + let args = if has_view_schema { + (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)? + } else { + (&view_id,).into_pyobject(py)? + }; + + Ok(handler + .call_method1( + py, + if has_view_schema { + pyo3::intern!(py, "view_schema") + } else { + pyo3::intern!(py, "table_schema") + }, + args, + )? + .downcast_bound::(py)? + .items() + .extract::>()? + .into_iter() + .map(|(k, v)| (k, ColumnType::from_str(&v).unwrap())) + .collect()) + }) + }) + } + + fn view_size(&self, view_id: &str) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + Box::pin(async move { + Python::with_gil(|py| { + handler + .call_method1(py, pyo3::intern!(py, "view_size"), (&view_id,))? + .extract::(py) + }) + }) + } + + fn view_column_size( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let config = config.clone(); + Box::pin(async move { + let has_table_column_size = + Python::with_gil(|py| handler.getattr(py, "view_column_size").is_ok()); + if has_table_column_size { + Python::with_gil(|py| { + handler + .call_method1( + py, + pyo3::intern!(py, "view_column_size"), + (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)?, + )? + .extract::(py) + }) + } else { + Ok(self.view_schema(&view_id, &config).await?.len() as u32) + } + }) + } + + fn view_delete(&self, view_id: &str) -> VirtualServerFuture<'_, Result<(), Self::Error>> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + Box::pin(async move { + Python::with_gil(|py| { + handler.call_method1(py, pyo3::intern!(py, "view_delete"), (&view_id,))?; + Ok(()) + }) + }) + } + + fn view_get_data( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + viewport: &perspective_client::proto::ViewPort, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let config = config.clone(); + let window: PyViewPort = viewport.clone().into(); + Box::pin(async move { + Python::with_gil(|py| { + let data = PyVirtualDataSlice::default(); + let _ = handler.call_method1( + py, + pyo3::intern!(py, "view_get_data"), + ( + &view_id, + pythonize::pythonize(py, &config)?, + pythonize::pythonize(py, &window)?, + data.clone(), + ), + )?; + + Ok(Mutex::into_inner(Arc::try_unwrap(data.0).unwrap()).unwrap()) + }) + }) + } +} + +#[derive(Serialize, PartialEq)] +pub struct PyViewPort { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_row: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_col: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_row: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_col: ::core::option::Option, +} + +impl From for PyViewPort { + fn from(value: perspective_client::proto::ViewPort) -> Self { + PyViewPort { + start_row: value.start_row, + start_col: value.start_col, + end_row: value.end_row, + end_col: value.end_col, + } + } +} + +#[derive(Clone, Default)] +#[pyclass(name = "VirtualDataSlice")] +pub struct PyVirtualDataSlice(Arc>); + +#[pymethods] +impl PyVirtualDataSlice { + #[pyo3(signature=(dtype, name, index, val, group_by_index = None))] + pub fn set_col( + &self, + dtype: &str, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + match dtype { + "string" => self.set_string_col(name, index, val, group_by_index), + "integer" => self.set_integer_col(name, index, val, group_by_index), + "float" => self.set_float_col(name, index, val, group_by_index), + "date" => self.set_datetime_col(name, index, val, group_by_index), + "datetime" => self.set_datetime_col(name, index, val, group_by_index), + "boolean" => self.set_boolean_col(name, index, val, group_by_index), + _ => Err(PyValueError::new_err("Unknown type")), + } + } + + #[pyo3(signature=(name, index, val, group_by_index = None))] + pub fn set_string_col( + &self, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.downcast_bound::(py) { + self.0 + .lock() + .unwrap() + .set_col( + name, + group_by_index, + index as usize, + val.extract::().ok(), + ) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, group_by_index = None))] + pub fn set_integer_col( + &self, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, group_by_index = None))] + pub fn set_float_col( + &self, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, group_by_index = None))] + pub fn set_boolean_col( + &self, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } + + #[pyo3(signature=(name, index, val, group_by_index = None))] + pub fn set_datetime_col( + &self, + name: &str, + index: u32, + val: Py, + group_by_index: Option, + ) -> PyResult<()> { + Python::with_gil(|py| { + if val.is_none(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Ok(val) = val.downcast_bound::(py) { + let dt: DateTime = Utc + .with_ymd_and_hms( + val.getattr("year")?.extract()?, + val.getattr("month")?.extract()?, + val.getattr("day")?.extract()?, + 0, + 0, + 0, + ) + .unwrap(); + let timestamp = dt.timestamp() * 1000; + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(timestamp)) + .unwrap(); + } else if let Ok(val) = val.extract::(py) { + self.0 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(val)) + .unwrap(); + } else { + tracing::error!("Unhandled") + }; + + Ok(()) + }) + } +} + +#[pyclass(name = "VirtualServer")] +pub struct PyVirtualServer(VirtualServer); + +#[pymethods] +impl PyVirtualServer { + #[new] + pub fn new(handler: Py) -> PyResult { + Ok(PyVirtualServer(VirtualServer::new(PyServerHandler( + handler, + )))) + } + + pub fn handle_request(&mut self, bytes: Py) -> PyResult> { + Python::with_gil(|py| { + let bytes_vec = bytes.as_bytes(py).to_vec(); + + // Use futures::executor::block_on to run the async code synchronously + let result = futures::executor::block_on(async { + self.0.handle_request(bytes::Bytes::from(bytes_vec)).await + }); + + match result.get_internal_error() { + Ok(x) => Ok(PyBytes::new(py, &x).unbind()), + Err(Ok(x)) => Err(x), + Err(Err(x)) => Err(PyValueError::new_err(x)), + } + }) + } +} diff --git a/rust/perspective-server/Cargo.toml b/rust/perspective-server/Cargo.toml index 3ba790709e..c9d540e2dc 100644 --- a/rust/perspective-server/Cargo.toml +++ b/rust/perspective-server/Cargo.toml @@ -42,15 +42,30 @@ disable-cpp = [] cmake = "0.1.50" num_cpus = "^1.15.0" shlex = "1.3.0" -protobuf-src = { version = "2.0.1" } +protobuf-src = { version = "2.1.1" } [dependencies] -link-cplusplus = "1.0.12" perspective-client = { version = "4.0.1" } + +# Key order is frequently implicitly relied upon in dynamic languages, so for +# convenience we try to provide this (as well as explicit metadata calls). +indexmap = { version = "2.2.6", features = ["serde"] } + +# Convenient way to crawl the C++ static archive path +link-cplusplus = "1.0.12" + async-lock = "2.5.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0.107", features = ["raw_value"] } tracing = { version = ">=0.1.36" } +thiserror = { version = "1.0.55" } futures = "0.3" +[dependencies.prost] +version = "0.12.3" +default-features = false +features = ["prost-derive", "std"] + [lib] crate-type = ["rlib"] path = "src/lib.rs" diff --git a/rust/perspective/Cargo.toml b/rust/perspective/Cargo.toml index ba0afe4fba..2fb8e49b16 100644 --- a/rust/perspective/Cargo.toml +++ b/rust/perspective/Cargo.toml @@ -42,5 +42,14 @@ perspective-client = { version = "4.0.1" } perspective-server = { version = "4.0.1" } tracing = { version = ">=0.1.36" } axum = { version = ">=0.8,<0.9", features = ["ws"], optional = true } +fallible-iterator = "0.3.0" +indexmap = "2.2.6" +serde = { version = "1.0" } +serde_json = { version = "1.0.107" } tokio = { version = "~1", features = ["full"], optional = true } futures = { version = "~0", optional = true } + +[dependencies.prost] +version = "0.12.3" +default-features = false +features = ["prost-derive", "std"] diff --git a/rust/perspective/src/lib.rs b/rust/perspective/src/lib.rs index 965c48bac2..fb270e5161 100644 --- a/rust/perspective/src/lib.rs +++ b/rust/perspective/src/lib.rs @@ -54,5 +54,7 @@ #[cfg(feature = "axum-ws")] pub mod axum; +pub mod virtual_server; +pub use perspective_client::proto; pub use {perspective_client as client, perspective_server as server}; diff --git a/rust/perspective/src/virtual_server.rs b/rust/perspective/src/virtual_server.rs new file mode 100644 index 0000000000..3bb7b255fa --- /dev/null +++ b/rust/perspective/src/virtual_server.rs @@ -0,0 +1,80 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::net::SocketAddr; + +use axum::extract::connect_info::ConnectInfo; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::routing::{MethodRouter, get}; +use perspective_client::virtual_server::{VirtualServer, VirtualServerHandler}; + +/// A local error synonym for this module only. +type PerspectiveWSError = Box; + +pub type PSPError = Box; + +/// The inner message loop handles the full-duplex stream of messages +/// between the [`perspective::Client`] and [`Session`]. When this +/// funciton returns, messages are no longer processed. +async fn process_message_loop( + socket: &mut WebSocket, + handler: impl VirtualServerHandler, +) -> Result<(), PerspectiveWSError> { + use Message::*; + let mut processor = VirtualServer::new(handler); + loop { + match socket.recv().await { + Some(Ok(Binary(msg))) => { + socket + .send(Binary(processor.handle_request(msg).await?)) + .await? + }, + Some(_) | None => { + tracing::debug!("Unexpected msg"); + break; + }, + }; + } + + Ok(()) +} + +/// This handler is responsible for the beginning-to-end lifecycle of a +/// single WebSocket connection to an [`axum`] server. +/// +/// Messages will come in from the [`axum::extract::ws::WebSocket`] in binary +/// form via [`Message::Binary`], where they'll be routed to +/// [`perspective::Session::handle_request`]. The server may generate +/// one or more responses, which it will then send back to +/// the [`axum::extract::ws::WebSocket::send`] method via its +/// [`SessionHandler`] impl. +pub fn custom_websocket_handler(handler: T) -> MethodRouter +where + T: VirtualServerHandler + Clone + Send + Sync + 'static, + S: Clone + Send + Sync + 'static, +{ + let websocket_handler_internal = async |ws: WebSocketUpgrade, + ConnectInfo(addr): ConnectInfo| + -> axum::response::Response { + tracing::info!("{addr} Connected."); + + ws.on_upgrade(move |mut socket| async move { + if let Err(msg) = process_message_loop(&mut socket, handler).await { + tracing::error!("Internal error {}", msg); + } + + tracing::info!("{addr} Disconnected."); + }) + }; + + get(websocket_handler_internal) +} From 2673940a9cf72426b9fa0d961982a98280ad33f9 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 9 Oct 2025 20:48:51 -0400 Subject: [PATCH 2/4] Add DuckDB virtual server for Python Signed-off-by: Andrew Stein WIP Signed-off-by: Andrew Stein WIP Signed-off-by: Andrew Stein --- examples/python-duckdb-virtual/index.html | 31 ++ examples/python-duckdb-virtual/package.json | 22 + examples/python-duckdb-virtual/server.py | 66 +++ .../src/ts/style_handlers/consolidated.ts | 22 +- pnpm-lock.yaml | 37 +- pnpm-workspace.yaml | 2 +- .../perspective/virtual_servers/__init__.py | 3 - .../perspective/virtual_servers/duckdb.py | 436 ++++++++++++++++++ 8 files changed, 590 insertions(+), 29 deletions(-) create mode 100644 examples/python-duckdb-virtual/index.html create mode 100644 examples/python-duckdb-virtual/package.json create mode 100644 examples/python-duckdb-virtual/server.py create mode 100644 rust/perspective-python/perspective/virtual_servers/duckdb.py diff --git a/examples/python-duckdb-virtual/index.html b/examples/python-duckdb-virtual/index.html new file mode 100644 index 0000000000..c173a35350 --- /dev/null +++ b/examples/python-duckdb-virtual/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/examples/python-duckdb-virtual/package.json b/examples/python-duckdb-virtual/package.json new file mode 100644 index 0000000000..fbdb042fec --- /dev/null +++ b/examples/python-duckdb-virtual/package.json @@ -0,0 +1,22 @@ +{ + "name": "python-duckdb-virtual", + "private": true, + "version": "3.7.4", + "description": "An example of streaming a `perspective-python` server to the browser.", + "scripts": { + "start": "PYTHONPATH=../../python/perspective python3 server.py" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:^", + "@perspective-dev/viewer": "workspace:^", + "@perspective-dev/viewer-d3fc": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@perspective-dev/workspace": "workspace:^", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "npm-run-all": "catalog:" + } +} diff --git a/examples/python-duckdb-virtual/server.py b/examples/python-duckdb-virtual/server.py new file mode 100644 index 0000000000..8538a92aa7 --- /dev/null +++ b/examples/python-duckdb-virtual/server.py @@ -0,0 +1,66 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ Copyright (c) 2017, the Perspective Authors. ┃ +# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +# ┃ This file is part of the Perspective library, distributed under the terms ┃ +# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +from pathlib import Path + +import duckdb +import perspective +import perspective.handlers.tornado +import perspective.virtual_servers.duckdb +import tornado.ioloop +import tornado.web +import tornado.websocket + +from loguru import logger +from tornado.web import StaticFileHandler + + +INPUT_FILE = ( + Path(__file__).parent.resolve() + / "node_modules" + / "superstore-arrow" + / "superstore.parquet" +) + + +if __name__ == "__main__": + db = duckdb.connect(":memory:perspective") + db.sql( + f""" + SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; + CREATE TABLE data_source_one AS + SELECT * FROM '{INPUT_FILE}'; + """, + ) + + virtual_server = perspective.virtual_servers.duckdb.DuckDBVirtualServer(db) + app = tornado.web.Application( + [ + ( + r"/websocket", + perspective.handlers.tornado.PerspectiveTornadoHandler, + {"perspective_server": virtual_server}, + ), + (r"/node_modules/(.*)", StaticFileHandler, {"path": "../../node_modules/"}), + ( + r"/(.*)", + StaticFileHandler, + {"path": "./", "default_filename": "index.html"}, + ), + ], + websocket_max_message_size=100 * 1024 * 1024, + ) + + app.listen(3000) + logger.info("Listening on http://localhost:3000") + loop = tornado.ioloop.IOLoop.current() + loop.start() diff --git a/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts index 71baba4833..7a51bbc0a3 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/consolidated.ts @@ -22,11 +22,6 @@ import type { SelectedPosition, } from "../types.js"; -import { cell_style_numeric } from "./table_cell/numeric.js"; -import { cell_style_string } from "./table_cell/string.js"; -import { cell_style_datetime } from "./table_cell/datetime.js"; -import { cell_style_boolean } from "./table_cell/boolean.js"; -import { cell_style_row_header } from "./table_cell/row_header.js"; import { applyFocusStyle } from "./focus.js"; import { styleColumnHeaderRow } from "./column_header.js"; import { applyColumnHeaderStyles } from "./editable.js"; @@ -117,13 +112,8 @@ export function createConsolidatedStyleListener( // Toggle edit mode class on datagrid datagrid.classList.toggle("edit-mode-allowed", isEditableAllowed); - - // ========== PHASE 1: Collect all metadata (READ PHASE) ========== const bodyCells: CollectedCell[] = []; - const headerCells: CollectedCell[] = []; const groupHeaderRows: CollectedHeaderRow[] = []; - - // Collect body cells (tbody) const tbody = regularTable.children[0]?.children[1]; if (tbody) { for (const tr of tbody.children) { @@ -131,6 +121,7 @@ export function createConsolidatedStyleListener( const metadata = regularTable.getMeta(cell) as | CellMetaExtended | undefined; + if (metadata) { const isHeader = cell.tagName === "TH"; bodyCells.push({ @@ -151,10 +142,12 @@ export function createConsolidatedStyleListener( row: tr as HTMLTableRowElement, cells: [], }; + for (const cell of tr.children) { const metadata = regularTable.getMeta(cell) as | CellMetadata | undefined; + rowData.cells.push({ element: cell as HTMLTableCellElement, metadata, @@ -164,9 +157,6 @@ export function createConsolidatedStyleListener( } } - // ========== PHASE 2: Apply all styles (WRITE PHASE) ========== - - // 2a. Style body cells this._applyBodyCellStyles( bodyCells, plugins, @@ -179,18 +169,12 @@ export function createConsolidatedStyleListener( viewer, ); - // 2b. Style group headers this._applyGroupHeaderStyles(groupHeaderRows, regularTable); - - // 2c. Style column headers this._applyColumnHeaderStyles(groupHeaderRows, regularTable, viewer); - - // 2d. Apply focus this._applyFocusStyle(bodyCells, regularTable, selectedPositionMap); }; } -// Extend DatagridModel prototype with styling methods declare module "../types.js" { interface DatagridModel { _applyBodyCellStyles( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2812084a1d..3555750899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,8 +151,8 @@ catalogs: specifier: ^18 version: 18.3.1 regular-table: - specifier: '=0.7.1' - version: 0.7.1 + specifier: '=0.7.2' + version: 0.7.2 stoppable: specifier: '=1.1.0' version: 1.1.0 @@ -413,6 +413,31 @@ importers: examples/python-aiohttp: {} + examples/python-duckdb-virtual: + dependencies: + '@perspective-dev/client': + specifier: workspace:^ + version: link:../../rust/perspective-js + '@perspective-dev/viewer': + specifier: workspace:^ + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-d3fc': + specifier: workspace:^ + version: link:../../packages/viewer-d3fc + '@perspective-dev/viewer-datagrid': + specifier: workspace:^ + version: link:../../packages/viewer-datagrid + '@perspective-dev/workspace': + specifier: workspace:^ + version: link:../../packages/workspace + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + devDependencies: + npm-run-all: + specifier: 'catalog:' + version: 4.1.5 + examples/python-starlette: {} examples/python-tornado: @@ -754,7 +779,7 @@ importers: version: 3.1.2 regular-table: specifier: 'catalog:' - version: 0.7.1 + version: 0.7.2 devDependencies: '@perspective-dev/esbuild-plugin': specifier: 'workspace:' @@ -7722,8 +7747,8 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true - regular-table@0.7.1: - resolution: {integrity: sha512-dIt9z+ZIHEhLujDbDMcuwJPZK6HVKFL1qnvyaUHrAbFx8/SoHJyvktwJdNpMkRfFwEdkmBYmtd5dei5BymxvZg==} + regular-table@0.7.2: + resolution: {integrity: sha512-IyAlxssZF6TGPh620Sjym6/7DH1pUPLFp816P4/0xFovU4oXi+40p5wRfTOQxlvIz73Sz6pb90hvKcrDsPnBBw==} engines: {node: '>=16'} rehype-raw@7.0.0: @@ -17636,7 +17661,7 @@ snapshots: dependencies: jsesc: 3.1.0 - regular-table@0.7.1: {} + regular-table@0.7.2: {} rehype-raw@7.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1db7d1ebcc..7e0c7daefb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,7 +39,7 @@ catalog: "pro_self_extracting_wasm": "0.0.9" "react-dom": "^18" "react": "^18" - "regular-table": "=0.7.1" + "regular-table": "=0.7.2" "stoppable": "=1.1.0" "ws": "^8.17.0" diff --git a/rust/perspective-python/perspective/virtual_servers/__init__.py b/rust/perspective-python/perspective/virtual_servers/__init__.py index faf9b4773b..841ff7ab32 100644 --- a/rust/perspective-python/perspective/virtual_servers/__init__.py +++ b/rust/perspective-python/perspective/virtual_servers/__init__.py @@ -81,9 +81,6 @@ def table_schema(self, table_name): pass - def table_columns_size(self, table_name, config): - pass - def table_size(self, table_name): """ Get a table's row count. Optionally, a model may also implement the diff --git a/rust/perspective-python/perspective/virtual_servers/duckdb.py b/rust/perspective-python/perspective/virtual_servers/duckdb.py new file mode 100644 index 0000000000..98dae80219 --- /dev/null +++ b/rust/perspective-python/perspective/virtual_servers/duckdb.py @@ -0,0 +1,436 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ Copyright (c) 2017, the Perspective Authors. ┃ +# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +# ┃ This file is part of the Perspective library, distributed under the terms ┃ +# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import duckdb +import perspective + +from datetime import datetime +from loguru import logger + +from perspective.virtual_servers import VirtualSessionModel + +# TODO(texodus): Missing these features +# +# - `min_max` API for value-coloring and value-sizing. +# +# - row expand/collapse in the datagrid needs datamodel support, this is +# likely a "collapsed" boolean column in the temp table we `UPDATE`. +# +# - `on_update` real-time support will be method which takes sa view name and +# a handler and calls the handler when the view needs to be recalculated. +# +# Nice to have: +# +# - Optional `view_change` method can be implemented for engine optimization, +# defaulting to just delete & recreate (as Perspective engine does now). +# +# - Would like to add a metadata API so that e.g. Viewer debug panel could +# show internal generated SQL. + + +NUMBER_AGGS = [ + "sum", + "count", + "any_value", + "arbitrary", + # "arg_max", + # "arg_max_null", + # "arg_min", + # "arg_min_null", + "array_agg", + "avg", + "bit_and", + "bit_or", + "bit_xor", + "bitstring_agg", + "bool_and", + "bool_or", + "countif", + "favg", + "fsum", + "geomean", + # "histogram", + # "histogram_values", + "kahan_sum", + "last", + # "list" + "max", + # "max_by" + "min", + # "min_by" + "product", + "string_agg", + "sumkahan", + # "weighted_avg", +] + +STRING_AGGS = [ + "count", + "any_value", + "arbitrary", + "first", + "countif", + "last", + "string_agg", +] + +FILTER_OPS = [ + "==", + "!=", + "LIKE", + "IS DISTINCT FROM", + "IS NOT DISTINCT FROM", + ">=", + "<=", + ">", + "<", +] + + +class DuckDBVirtualSession: + def __init__(self, callback, db): + self.session = perspective.VirtualServer(DuckDBVirtualSessionModel(db)) + self.callback = callback + + def handle_request(self, msg): + self.callback(self.session.handle_request(msg)) + + +class DuckDBVirtualServer: + def __init__(self, db): + self.db = db + + def new_session(self, callback): + return DuckDBVirtualSession(callback, self.db) + + +class DuckDBVirtualSessionModel(VirtualSessionModel): + """ + An implementation of a `perspective.VirtualSessionModel` for DuckDB. + """ + + def __init__(self, db): + self.db = db + + def get_features(self): + return { + "group_by": True, + "split_by": True, + "sort": True, + "expressions": True, + "filter_ops": { + "integer": FILTER_OPS, + "float": FILTER_OPS, + "string": FILTER_OPS, + "boolean": FILTER_OPS, + "date": FILTER_OPS, + "datetime": FILTER_OPS, + }, + "aggregates": { + "integer": NUMBER_AGGS, + "float": NUMBER_AGGS, + "string": STRING_AGGS, + "boolean": STRING_AGGS, + "date": STRING_AGGS, + "datetime": STRING_AGGS, + }, + } + + def get_hosted_tables(self): + logger.info("SHOW ALL TABLES") + results = self.db.sql("SHOW ALL TABLES").fetchall() + return [result[2] for result in results] + + def table_schema(self, table_name): + query = f"DESCRIBE {table_name}" + results = run_query(self.db, query) + return { + result[0].split("_")[-1]: duckdb_type_to_psp(result[1]) + for result in results + if not (result[0].startswith("__") and result[0].endswith("__")) + } + + def view_column_size(self, table_name, config): + # TODO split this into 2 methods + query = f"SELECT COUNT(*) FROM (DESCRIBE {table_name})" + results = run_query(self.db, query) + gs = len(config["group_by"]) + return results[0][0] - ( + 0 if gs == 0 else gs + (1 if len(config["split_by"]) == 0 else 0) + ) + + def table_size(self, table_name): + query = f"SELECT COUNT(*) FROM {table_name}" + results = run_query(self.db, query) + return results[0][0] + + def table_make_view(self, table_name, view_name, config): + columns = config["columns"] + group_by = config["group_by"] + split_by = config["split_by"] + aggregates = config["aggregates"] + sort = config["sort"] + + def col_name(col): + return expr if (expr := config["expressions"].get(col)) else f'"{col}"' + + def select_clause(): + if len(group_by) > 0: + for col in columns: + yield f'{aggregates.get(col)}({col_name(col)}) as "{col}"' + + if len(split_by) == 0: + for idx, group in enumerate(group_by): + yield f"{col_name(group)} as __ROW_PATH_{idx}__" + + groups = ", ".join(col_name(g) for g in group_by) + yield f"GROUPING_ID({groups}) AS __GROUPING_ID__" + elif len(columns) > 0: + for col in columns: + yield f'''{col_name(col)} as "{col.replace('"', '""')}"''' + + def order_by_clause(): + if len(group_by) > 0: + for gidx in range(len(group_by)): + groups = ", ".join(col_name(g) for g in group_by[: (gidx + 1)]) + if len(split_by) == 0: + yield f"""GROUPING_ID({groups}) DESC""" + + for sort_col, sort_dir in sort: + if sort_dir != "none": + agg = aggregates.get(sort_col) + if gidx >= len(group_by) - 1: + yield f"{agg}({col_name(sort_col)}) {sort_dir}" + else: + yield f""" + first({agg}({col_name(sort_col)})) + OVER __WINDOW_{gidx}__ {sort_dir} + """ + + yield f"__ROW_PATH_{gidx}__ ASC" + else: + for sort_col, sort_dir in sort: + if sort_dir is not None: + yield f"{col_name(sort_col)} {sort_dir}" + + def window_clause(): + if len(config["sort"]) == 0: + return + + for gidx in range(len(group_by) - 1): + partition = ", ".join(f"__ROW_PATH_{i}__" for i in range(gidx + 1)) + sub_groups = ", ".join(col_name(g) for g in group_by[: (gidx + 1)]) + groups = ", ".join(col_name(g) for g in group_by) + yield f""" + __WINDOW_{gidx}__ AS ( + PARTITION BY + GROUPING_ID({sub_groups}), + {partition} + ORDER BY + {groups} + )""" + + def where_clause(): + for name, op, value in config["filter"]: + if value is not None: + term_lit = f"'{value}'" if isinstance(value, str) else str(value) + yield f"{col_name(name)} {op} {term_lit}" + + if len(split_by) > 0: + query = "SELECT * FROM {}".format(table_name) + else: + query = "SELECT {} FROM {}".format(", ".join(select_clause()), table_name) + + # else: + # for split in split_by: + # extra_cols_query = f""" + # SELECT DISTINCT {f'"{split}"'} + # FROM {table_name} + # """ + # results = self.db.sql(extra_cols_query).fetchall() + # real_columns = [] + # for result in results: + # for idx, col in enumerate(columns): + # real_columns.append( + # f'"{result[0]}_{col}" AS "{result[0]}|{col}"' + # ) + + if len(where := list(where_clause())) > 0: + query = "{} WHERE {}".format(query, " AND ".join(where)) + + if len(split_by) > 0: + groups = ", ".join(col_name(x) for x in group_by) + group_aliases = ", ".join( + f"{col_name(x)} AS __ROW_PATH_{i}__" for i, x in enumerate(group_by) + ) + + query = f""" + SELECT * EXCLUDE ({groups}), {group_aliases} FROM ( + PIVOT ({query}) + ON {", ".join(f'"{c}"' for c in split_by)} + USING {", ".join(select_clause())} + GROUP BY {groups} + ) + """ + + elif len(group_by) > 0: + groups = ", ".join(col_name(x) for x in group_by) + query = f"{query} GROUP BY ROLLUP({groups})" + + if len(window := list(window_clause())) > 0: + query = f"{query} WINDOW {', '.join(window)}" + + if len(order_by := list(order_by_clause())) > 0: + query = f"{query} ORDER BY {', '.join(order_by)}" + + query = f"CREATE TEMPORARY TABLE {view_name} AS ({query})" + run_query(self.db, query, execute=True) + + def table_validate_expression(self, view_name, expression): + query = f"DESCRIBE (select {expression} from {view_name})" + results = run_query(self.db, query) + return duckdb_type_to_psp(results[0][1]) + + def view_delete(self, view_name): + query = f"DROP TABLE {view_name}" + run_query(self.db, query, execute=True) + + def view_get_data(self, view_name, config, viewport, data): + group_by = config["group_by"] + split_by = config["split_by"] + start_col = viewport.get("start_col") + end_col = viewport.get("end_col") + + limit = "" + if (end_row := viewport.get("end_row")) is not None: + start_row = viewport.get("start_row", 0) + limit = f"LIMIT {end_row - start_row} OFFSET {start_row}" + + col_limit = "" + if end_col is not None: + col_limit = f"LIMIT {end_col - start_col} OFFSET {start_col}" + + group_by_columns = "" + if len(group_by) > 0: + if len(split_by) == 0: + row_paths = ["__GROUPING_ID__"] + else: + row_paths = [] + + row_paths.extend(f"__ROW_PATH_{idx}__" for idx in range(len(group_by))) + group_by_columns = f"{', '.join(row_paths)}," + + query = f""" + SET VARIABLE col_names = ( + SELECT list(column_name) FROM ( + SELECT column_name + FROM (DESCRIBE {view_name}) + WHERE not(starts_with(column_name, '__')) + {col_limit} + ) + ); + + SELECT + {group_by_columns} + COLUMNS(c -> list_contains(getvariable('col_names'), c)) + FROM {view_name} {limit} + """ + + results, columns, dtypes = run_query(self.db, query, columns=True) + for cidx, col in enumerate(columns): + if cidx == 0 and len(group_by) > 0 and len(split_by) == 0: + continue + + group_by_index = None + max_grouping_id = None + if len(prefix := col.split("__ROW_PATH_")) > 1: + group_by_index = int(prefix[1].split("__")[0]) + max_grouping_id = 2 ** (len(group_by) - group_by_index) - 1 + + for ridx, row in enumerate(results): + dtype = duckdb_type_to_psp(dtypes[cidx]) + if ( + len(split_by) > 0 + or max_grouping_id is None + or row[0] < max_grouping_id + ): + data.set_col( + dtype, + col.replace("_", "|"), + ridx, + row[cidx], + group_by_index=group_by_index, + ) + + +################################################################################ +# +# DuckDB Utils + + +def val_to_duckdb_lit(value): + """ + Convert a Python value to a string representation of this values suitable + for SQL injecting. + """ + if isinstance(value, str): + return f"'{value}'" + return str(value) + + +def sort_to_duckdb_sort(sortdir): + if sortdir == "asc": + return "ASC" + if sortdir == "desc": + return "DESC" + return "DESC" + + +def duckdb_type_to_psp(name): + """Convert a DuckDB `dtype` to a Perspective `ColumnType`.""" + if name == "VARCHAR": + return "string" + if name in ("DOUBLE", "BIGINT", "HUGEINT"): + return "float" + if name == "INTEGER": + return "integer" + if name == "DATE": + return "date" + if name == "BOOLEAN": + return "boolean" + if name == "TIMESTAMP": + return "datetime" + + msg = f"Unknown type '{name}'" + raise ValueError(msg) + + +def run_query(db, query, execute=False, columns=False): + query = " ".join(query.split()) + start = datetime.now() + result = None + try: + if execute: + db.execute(query) + else: + req = db.sql(query) + result = req.fetchall() + except (duckdb.ParserException, duckdb.BinderException) as e: + logger.error(e) + logger.error(f"{query}") + raise e + else: + logger.debug(f"{datetime.now() - start} {query}") + if columns: + return (result, req.columns, req.dtypes) + else: + return result From 1189ebf13d41f1a3befd1e6e8026a5cd45b6bca1 Mon Sep 17 00:00:00 2001 From: Timothy Bess Date: Tue, 13 Jan 2026 17:33:43 -0500 Subject: [PATCH 3/4] DuckDB WASM Virtual Server Signed-off-by: Andrew Stein # Conflicts: # Cargo.lock # rust/perspective-js/Cargo.toml # rust/perspective-python/Cargo.toml # rust/perspective-viewer/src/rust/custom_elements/viewer.rs # Conflicts: # rust/perspective-python/src/server/virtual_server_sync.rs # rust/perspective-server/src/virtual_server.rs --- Cargo.toml | 2 + examples/nodejs-virtual-server/build.js | 57 ++ examples/nodejs-virtual-server/package.json | 25 + examples/nodejs-virtual-server/server.mjs | 36 + examples/nodejs-virtual-server/src/index.html | 25 + examples/nodejs-virtual-server/src/index.js | 85 ++ examples/nodejs-virtual-server/src/worker.js | 638 +++++++++++++++ rust/perspective-js/src/rust/lib.rs | 3 + rust/perspective-js/src/rust/server.rs | 759 ++++++++++++++++++ rust/perspective-js/src/rust/table_data.rs | 26 +- rust/perspective-js/src/rust/utils/errors.rs | 5 +- .../src/ts/perspective.browser.ts | 1 + .../perspective-js/src/ts/perspective.node.ts | 1 + rust/perspective-js/src/ts/virtual_server.ts | 146 ++++ rust/perspective-python/Cargo.toml | 1 + .../src/server/virtual_server_sync.rs | 104 +-- rust/perspective-server-virtual/Cargo.toml | 44 + rust/perspective-server-virtual/src/lib.rs | 586 ++++++++++++++ rust/perspective-server/build.mjs | 8 +- rust/perspective-viewer/Cargo.toml | 2 + rust/perspective-viewer/src/rust/lib.rs | 1 + rust/perspective-viewer/src/rust/session.rs | 15 +- 22 files changed, 2478 insertions(+), 92 deletions(-) create mode 100644 examples/nodejs-virtual-server/build.js create mode 100644 examples/nodejs-virtual-server/package.json create mode 100644 examples/nodejs-virtual-server/server.mjs create mode 100644 examples/nodejs-virtual-server/src/index.html create mode 100644 examples/nodejs-virtual-server/src/index.js create mode 100644 examples/nodejs-virtual-server/src/worker.js create mode 100644 rust/perspective-js/src/rust/server.rs create mode 100644 rust/perspective-js/src/ts/virtual_server.ts create mode 100644 rust/perspective-server-virtual/Cargo.toml create mode 100644 rust/perspective-server-virtual/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index fb3c21bd1c..52cbf1306a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "rust/perspective-js", "rust/perspective-python", "rust/perspective-server", + "rust/perspective-server-virtual", "rust/perspective-viewer", "examples/rust-axum", ] @@ -49,6 +50,7 @@ strip = true # simd-adler32 = { git = "https://github.com/mcountryman/simd-adler32.git", rev = "b279034d9eb554c3e5e0af523db044f08d8297ba" } perspective-client = { path = "rust/perspective-client" } perspective-server = { path = "rust/perspective-server" } +perspective-server-virtual = { path = "rust/perspective-server-virtual" } perspective-js = { path = "rust/perspective-js" } perspective = { path = "rust/perspective" } perspective-viewer = { path = "rust/perspective-viewer" } diff --git a/examples/nodejs-virtual-server/build.js b/examples/nodejs-virtual-server/build.js new file mode 100644 index 0000000000..a4fc58728d --- /dev/null +++ b/examples/nodejs-virtual-server/build.js @@ -0,0 +1,57 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import esbuild from "esbuild"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function build() { + await esbuild.build({ + entryPoints: ["src/index.js"], + outdir: "dist", + format: "esm", + bundle: true, + sourcemap: "inline", + target: "es2022", + loader: { + ".ttf": "file", + ".wasm": "file", + }, + assetNames: "[name]", + }); + + await esbuild.build({ + entryPoints: ["src/worker.js"], + outdir: "dist", + format: "esm", + bundle: true, + sourcemap: "inline", + target: "es2022", + loader: { + ".ttf": "file", + ".wasm": "file", + ".arrow": "file", + }, + assetNames: "[name]", + }); + + fs.writeFileSync( + path.join(__dirname, "dist/index.html"), + fs.readFileSync(path.join(__dirname, "src/index.html")).toString(), + ); +} + +build(); diff --git a/examples/nodejs-virtual-server/package.json b/examples/nodejs-virtual-server/package.json new file mode 100644 index 0000000000..95e9cecdfb --- /dev/null +++ b/examples/nodejs-virtual-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "nodejs-virtual-server", + "private": true, + "version": "3.8.0", + "type": "module", + "description": "Example of a custom VirtualServer running in a Web Worker", + "scripts": { + "build": "node build.js", + "start": "node build.js && node server.mjs" + }, + "keywords": [], + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "workspace:^", + "@perspective-dev/server": "workspace:^", + "@perspective-dev/viewer": "workspace:^", + "@perspective-dev/viewer-d3fc": "workspace:^", + "@perspective-dev/viewer-datagrid": "workspace:^", + "@duckdb/duckdb-wasm": "^1.30.0", + "superstore-arrow": "catalog:" + }, + "devDependencies": { + "esbuild": "catalog:" + } +} diff --git a/examples/nodejs-virtual-server/server.mjs b/examples/nodejs-virtual-server/server.mjs new file mode 100644 index 0000000000..7311d243a3 --- /dev/null +++ b/examples/nodejs-virtual-server/server.mjs @@ -0,0 +1,36 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Example of using VirtualServer in a Web Worker + * + * This demonstrates how to create a custom data source using the VirtualServer API + * running client-side in a Web Worker. The VirtualServer implementation is in worker.js, + * and this server simply serves the static files. + */ + +import http from "http"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import { cwd_static_file_handler } from "@perspective-dev/client"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Create HTTP server for serving static files +const httpServer = http.createServer((req, res) => + cwd_static_file_handler(req, res, [`${__dirname}/dist`, __dirname]), +); + +httpServer.listen(8080, () => { + console.log("Server listening on http://localhost:8080"); + console.log("Open your browser to see the VirtualServer running in a Web Worker!"); +}); diff --git a/examples/nodejs-virtual-server/src/index.html b/examples/nodejs-virtual-server/src/index.html new file mode 100644 index 0000000000..f44c409e5d --- /dev/null +++ b/examples/nodejs-virtual-server/src/index.html @@ -0,0 +1,25 @@ + + + + + + Web Worker VirtualServer Example + + + + + + + + diff --git a/examples/nodejs-virtual-server/src/index.js b/examples/nodejs-virtual-server/src/index.js new file mode 100644 index 0000000000..c9f9aec5d7 --- /dev/null +++ b/examples/nodejs-virtual-server/src/index.js @@ -0,0 +1,85 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +window.queueMicrotask = undefined; + +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import "@perspective-dev/viewer-d3fc"; + +import "@perspective-dev/viewer/dist/css/themes.css"; +import "@perspective-dev/viewer/dist/css/pro.css"; + +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; + +await Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), +]); + +await customElements.whenDefined("perspective-viewer"); + +// Create a worker that hosts the VirtualServer +const worker = new Worker(new URL("./worker.js", import.meta.url), { + type: "module", +}); + +const client = await perspective.worker(Promise.resolve(worker)); + +const table = await client.open_table("data_source_one"); + +const viewer = document.querySelector("perspective-viewer"); +await viewer.load(table); +viewer.restore({ + version: "3.8.0", + plugin: "Datagrid", + plugin_config: { + columns: {}, + edit_mode: "READ_ONLY", + scroll_lock: false, + }, + columns_config: {}, + settings: false, + theme: "Pro Light", + title: null, + group_by: ["State"], + split_by: [], + sort: [], + filter: [], + expressions: {}, + columns: [ + "Row ID", + "Order ID", + "Order Date", + "Ship Date", + "Ship Mode", + "Customer ID", + "Customer Name", + "Segment", + "Country", + "City", + // "State", + "Postal Code", + "Region", + "Product ID", + "Category", + "Sub-Category", + "Product Name", + "Sales", + "Quantity", + "Discount", + "Profit", + ], + aggregates: {}, +}); diff --git a/examples/nodejs-virtual-server/src/worker.js b/examples/nodejs-virtual-server/src/worker.js new file mode 100644 index 0000000000..d6babde947 --- /dev/null +++ b/examples/nodejs-virtual-server/src/worker.js @@ -0,0 +1,638 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import perspective_client from "@perspective-dev/client"; +import { VirtualServer } from "@perspective-dev/client"; +import * as duckdb from "@duckdb/duckdb-wasm"; + +import CLIENT_WASM from "@perspective-dev/client/dist/wasm/perspective-js.wasm"; +import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow"; + +const NUMBER_AGGS = [ + "sum", + "count", + "any_value", + "arbitrary", + "array_agg", + "avg", + "bit_and", + "bit_or", + "bit_xor", + "bitstring_agg", + "bool_and", + "bool_or", + "countif", + "favg", + "fsum", + "geomean", + "kahan_sum", + "last", + "max", + "min", + "product", + "string_agg", + "sumkahan", +]; + +const STRING_AGGS = [ + "count", + "any_value", + "arbitrary", + "first", + "countif", + "last", + "string_agg", +]; + +const FILTER_OPS = [ + "==", + "!=", + "LIKE", + "IS DISTINCT FROM", + "IS NOT DISTINCT FROM", + ">=", + "<=", + ">", + "<", +]; + +let db; + +const tableMetadata = { + tables: [], + schemas: new Map(), +}; + +function duckdbTypeToPsp(name) { + if (name === "VARCHAR") return "string"; + if (name === "DOUBLE" || name === "BIGINT" || name === "HUGEINT") + return "float"; + if (name.startsWith("Decimal")) return "float"; + if (name.startsWith("Int")) return "integer"; + if (name === "INTEGER") return "integer"; + if (name === "Utf8") return "string"; + if (name === "Date32") return "date"; + if (name === "Float64") return "float"; + if (name === "DATE") return "date"; + if (name === "BOOLEAN") return "boolean"; + if (name === "TIMESTAMP") return "datetime"; + throw new Error(`Unknown type '${name}'`); +} + +function convertDecimalToNumber(value, dtypeString) { + if ( + value === null || + value === undefined || + !(value instanceof Uint32Array || value instanceof Int32Array) + ) { + return value; + } + + let bigIntValue = BigInt(0); + for (let i = 0; i < value.length; i++) { + bigIntValue |= BigInt(value[i]) << BigInt(i * 32); + } + + const maxInt128 = BigInt(2) ** BigInt(127); + if (bigIntValue >= maxInt128) { + bigIntValue -= BigInt(2) ** BigInt(128); + } + + const scaleMatch = dtypeString.match(/Decimal\[\d+e(\d+)\]/); + const scale = scaleMatch ? parseInt(scaleMatch[1]) : 0; + + if (scale > 0) { + return Number(bigIntValue) / Math.pow(10, scale); + } else { + return Number(bigIntValue); + } +} + +async function runQuery(query, options = {}) { + query = query.replace(/\s+/g, " ").trim(); + console.log("Query:", query); + /** @type {duckdb.AsyncDuckDBConnection} */ + const c = await db.connect(); + try { + const result = await c.query(query); + if (options.columns) { + return { + rows: result.toArray(), + columns: result.schema.fields.map((f) => f.name), + dtypes: result.schema.fields.map((f) => f.type.toString()), + }; + } + return result.toArray(); + } catch (error) { + console.error("Query error:", error); + console.error("Query:", query); + throw error; + } finally { + await c.close(); + } +} + +const handler = { + getFeatures() { + return { + group_by: true, + split_by: true, + sort: true, + expressions: true, + filter_ops: { + integer: FILTER_OPS, + float: FILTER_OPS, + string: FILTER_OPS, + boolean: FILTER_OPS, + date: FILTER_OPS, + datetime: FILTER_OPS, + }, + aggregates: { + integer: NUMBER_AGGS, + float: NUMBER_AGGS, + string: STRING_AGGS, + boolean: STRING_AGGS, + date: STRING_AGGS, + datetime: STRING_AGGS, + }, + }; + }, + + getHostedTables() { + return tableMetadata.tables; + }, + + async tableSchema(tableId) { + const query = `DESCRIBE ${tableId}`; + const results = await runQuery(query); + const schema = {}; + for (const result of results) { + const res = result.toJSON(); + const colName = res.column_name; + if (!colName.startsWith("__") || !colName.endsWith("__")) { + const cleanName = colName.split("_").slice(-1)[0]; + schema[cleanName] = duckdbTypeToPsp(res.column_type); + } + } + return schema; + }, + + async tableColumnsSize(tableId, config) { + const query = `SELECT COUNT(*) FROM (DESCRIBE ${tableId})`; + const results = await runQuery(query); + const gs = config.group_by?.length || 0; + const count = Number(Object.values(results[0].toJSON())[0]); + return ( + count - + (gs === 0 ? 0 : gs + (config.split_by?.length === 0 ? 1 : 0)) + ); + }, + + async tableSize(tableId) { + const query = `SELECT COUNT(*) FROM ${tableId}`; + const results = await runQuery(query); + return Number(results[0].toJSON()["count_star()"]); + }, + + async viewSchema(viewId, config) { + return this.tableSchema(viewId); + }, + + async viewSize(viewId) { + return this.tableSize(viewId); + }, + + async tableMakeView(tableId, viewId, config) { + console.log(`tableMakeView: ${tableId} -> ${viewId}`, config); + console.log(`aggregates:`, JSON.stringify(config.aggregates, null, 2)); + + const columns = config.columns || []; + const group_by = config.group_by || []; + const split_by = config.split_by || []; + const aggregates = config.aggregates || {}; + const sort = config.sort || []; + const expressions = config.expressions || {}; + const filter = config.filter || []; + + const colName = (col) => { + const expr = expressions[col]; + return expr || `"${col}"`; + }; + + const getAggregate = (col) => aggregates[col] || null; + + const generateSelectClauses = () => { + const clauses = []; + if (group_by.length > 0) { + for (const col of columns) { + const agg = getAggregate(col) || "any_value"; + clauses.push(`${agg}(${colName(col)}) as "${col}"`); + } + + if (split_by.length === 0) { + for (let idx = 0; idx < group_by.length; idx++) { + clauses.push( + `${colName(group_by[idx])} as __ROW_PATH_${idx}__`, + ); + } + + const groups = group_by.map(colName).join(", "); + clauses.push(`GROUPING_ID(${groups}) AS __GROUPING_ID__`); + } + } else if (columns.length > 0) { + for (const col of columns) { + clauses.push( + `${colName(col)} as "${col.replace(/"/g, '""')}"`, + ); + } + } + return clauses; + }; + + const orderByClauses = []; + const windowClauses = []; + const whereClauses = []; + + if (group_by.length > 0) { + for (let gidx = 0; gidx < group_by.length; gidx++) { + const groups = group_by + .slice(0, gidx + 1) + .map(colName) + .join(", "); + if (split_by.length === 0) { + orderByClauses.push(`GROUPING_ID(${groups}) DESC`); + } + + for (const [sort_col, sort_dir] of sort) { + if (sort_dir !== "none") { + const agg = getAggregate(sort_col) || "any_value"; + if (gidx >= group_by.length - 1) { + orderByClauses.push( + `${agg}(${colName(sort_col)}) ${sort_dir}`, + ); + } else { + orderByClauses.push( + `first(${agg}(${colName(sort_col)})) OVER __WINDOW_${gidx}__ ${sort_dir}`, + ); + } + } + } + + orderByClauses.push(`__ROW_PATH_${gidx}__ ASC`); + } + } else { + for (const [sort_col, sort_dir] of sort) { + if (sort_dir) { + orderByClauses.push(`${colName(sort_col)} ${sort_dir}`); + } + } + } + + if (sort.length > 0 && group_by.length > 1) { + for (let gidx = 0; gidx < group_by.length - 1; gidx++) { + const partition = Array.from( + { length: gidx + 1 }, + (_, i) => `__ROW_PATH_${i}__`, + ).join(", "); + const sub_groups = group_by + .slice(0, gidx + 1) + .map(colName) + .join(", "); + const groups = group_by.map(colName).join(", "); + windowClauses.push( + `__WINDOW_${gidx}__ AS (PARTITION BY GROUPING_ID(${sub_groups}), ${partition} ORDER BY ${groups})`, + ); + } + } + + for (const [name, op, value] of filter) { + if (value !== null && value !== undefined) { + const term_lit = + typeof value === "string" ? `'${value}'` : String(value); + whereClauses.push(`${colName(name)} ${op} ${term_lit}`); + } + } + + let query; + if (split_by.length > 0) { + query = `SELECT * FROM ${tableId}`; + } else { + const selectClauses = generateSelectClauses(); + query = `SELECT ${selectClauses.join(", ")} FROM ${tableId}`; + } + + if (whereClauses.length > 0) { + query = `${query} WHERE ${whereClauses.join(" AND ")}`; + } + + if (split_by.length > 0) { + const groups = group_by.map(colName).join(", "); + const group_aliases = group_by + .map((x, i) => `${colName(x)} AS __ROW_PATH_${i}__`) + .join(", "); + const pivotOn = split_by.map((c) => `"${c}"`).join(", "); + const pivotUsing = generateSelectClauses().join(", "); + + query = ` + SELECT * EXCLUDE (${groups}), ${group_aliases} FROM ( + PIVOT (${query}) + ON ${pivotOn} + USING ${pivotUsing} + GROUP BY ${groups} + ) + `; + } else if (group_by.length > 0) { + const groups = group_by.map(colName).join(", "); + query = `${query} GROUP BY ROLLUP(${groups})`; + } + + if (windowClauses.length > 0) { + query = `${query} WINDOW ${windowClauses.join(", ")}`; + } + + if (orderByClauses.length > 0) { + query = `${query} ORDER BY ${orderByClauses.join(", ")}`; + } + + query = `CREATE TABLE ${viewId} AS (${query})`; + await runQuery(query); + }, + + async tableValidateExpression(tableId, expression) { + const query = `DESCRIBE (select ${expression} from ${tableId})`; + const results = await runQuery(query); + return duckdbTypeToPsp(results[0][1]); + }, + + async viewDelete(viewId) { + console.log(`Deleting view ${viewId}`); + const query = `DROP TABLE IF EXISTS ${viewId}`; + await runQuery(query); + }, + + async viewGetData(viewId, config, viewport, dataSlice) { + console.log(`viewGetData: ${viewId}`, viewport); + + const group_by = config.group_by || []; + const split_by = config.split_by || []; + const start_col = viewport.start_col; + const end_col = viewport.end_col; + const start_row = viewport.start_row || 0; + const end_row = viewport.end_row; + + let limit = ""; + if (end_row !== null && end_row !== undefined) { + limit = `LIMIT ${end_row - start_row} OFFSET ${start_row}`; + } + + const schemaQuery = `DESCRIBE ${viewId}`; + const schemaResults = await runQuery(schemaQuery); + const columnTypes = new Map(); + for (const result of schemaResults) { + const res = result.toJSON(); + columnTypes.set(res.column_name, res.column_type); + } + + const dataColumns = Array.from(columnTypes.entries()) + .filter(([colName]) => !colName.startsWith("__")) + .slice(start_col, end_col); + + const groupByColsList = []; + if (group_by.length > 0) { + if (split_by.length === 0) { + groupByColsList.push("__GROUPING_ID__"); + } + for (let idx = 0; idx < group_by.length; idx++) { + groupByColsList.push(`__ROW_PATH_${idx}__`); + } + } + + const allColumns = [ + ...groupByColsList.map((col) => `"${col}"`), + ...dataColumns.map(([colName]) => `"${colName}"`), + ]; + + const query = ` + SELECT ${allColumns.join(", ")} + FROM ${viewId} ${limit} + `; + + const { rows, columns, dtypes } = await runQuery(query, { + columns: true, + }); + + console.log("viewGetData columns:", columns); + console.log("viewGetData dtypes:", dtypes); + console.log( + "viewGetData first 3 rows:", + rows.slice(0, 3).map((r) => r.toArray()), + ); + + for (let cidx = 0; cidx < columns.length; cidx++) { + const col = columns[cidx]; + + if (cidx === 0 && group_by.length > 0 && split_by.length === 0) { + continue; + } + + let group_by_index = null; + let max_grouping_id = null; + const row_path_match = col.match(/__ROW_PATH_(\d+)__/); + if (row_path_match) { + group_by_index = parseInt(row_path_match[1]); + max_grouping_id = 2 ** (group_by.length - group_by_index) - 1; + } + + const dtype = duckdbTypeToPsp(dtypes[cidx]); + const isDecimal = dtypes[cidx].startsWith("Decimal"); + const colName = + group_by_index !== null + ? "__ROW_PATH__" + : col.replace(/_/g, "|"); + + for (let ridx = 0; ridx < rows.length; ridx++) { + const row = rows[ridx]; + const rowArray = row.toArray(); + const shouldSet = + split_by.length > 0 || + max_grouping_id === null || + rowArray[0] < max_grouping_id; + + if (shouldSet) { + let value = rowArray[cidx]; + + if (isDecimal) { + value = convertDecimalToNumber(value, dtypes[cidx]); + } + + if (typeof value === "bigint") { + value = Number(value); + } + + dataSlice.setCol( + dtype, + colName, + ridx, + value, + group_by_index, + ); + } + } + } + }, +}; + +async function initializeDuckDB() { + const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); + + const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); + + const worker_url = URL.createObjectURL( + new Blob([`importScripts("${bundle.mainWorker}");`], { + type: "text/javascript", + }), + ); + + const worker = new Worker(worker_url); + const logger = new duckdb.ConsoleLogger(); + db = new duckdb.AsyncDuckDB(logger, worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + URL.revokeObjectURL(worker_url); + + const c = await db.connect(); + await c.query(` + SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; + `); + await c.close(); + + globalThis.db = db; + console.log("DuckDB initialized"); +} + +async function loadSampleData() { + const c = await db.connect(); + + try { + const response = await fetch(SUPERSTORE_ARROW); + const arrayBuffer = await response.arrayBuffer(); + + await c.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { + name: "data_source_one", + create: true, + }); + + const checkResult = await c.query( + "SELECT COUNT(*) as cnt FROM data_source_one", + ); + const rowCount = checkResult.toArray()[0].cnt; + console.log(`Superstore data loaded from Arrow IPC (${rowCount} rows)`); + } catch (error) { + console.error("Error loading Arrow data:", error); + await c.query(` + CREATE TABLE data_source_one AS + SELECT * FROM ( + VALUES + ('Furniture', 'Office Supplies', 'East', 100.0, 10), + ('Technology', 'Electronics', 'West', 200.0, 20), + ('Furniture', 'Chairs', 'East', 150.0, 15), + ('Technology', 'Computers', 'South', 300.0, 30) + ) AS t(Category, "Sub-Category", Region, Sales, Quantity) + `); + console.log("Loaded fallback sample data"); + } finally { + await c.close(); + } + + await cacheTableMetadata(); +} + +async function cacheTableMetadata() { + try { + console.log("Caching table metadata..."); + + const results = await runQuery("SHOW ALL TABLES"); + console.log("SHOW ALL TABLES results:", results); + + tableMetadata.tables = results.map((row) => row.toJSON().name); + + console.log("Extracted table names:", tableMetadata.tables); + + for (const tableName of tableMetadata.tables) { + const query = `DESCRIBE ${tableName}`; + const schemaResults = await runQuery(query); + const schema = {}; + for (const result of schemaResults) { + const res = result.toJSON(); + const colName = res.column_name; + if (!colName.startsWith("__") || !colName.endsWith("__")) { + const cleanName = colName.split("_").slice(-1)[0]; + schema[cleanName] = duckdbTypeToPsp(res.column_type); + } + } + tableMetadata.schemas.set(tableName, schema); + } + + console.log("Cached metadata for tables:", tableMetadata.tables); + console.log("Full metadata:", tableMetadata); + } catch (error) { + console.error("Error caching table metadata:", error); + throw error; + } +} + +let virtualServer; +let port; + +function bindPort(e) { + port = e.ports ? e.ports[0] : self; + + port.addEventListener("message", async (msg) => { + if (msg.data.cmd === "init") { + try { + await initializeDuckDB(); + await perspective_client.init_client(fetch(CLIENT_WASM)); + await loadSampleData(); + + virtualServer = new VirtualServer(handler); + + console.log("VirtualServer initialized"); + + if (msg.data.id !== undefined) { + port.postMessage({ id: msg.data.id }); + } else { + port.postMessage(null); + } + } catch (error) { + console.error("Error initializing worker:", error); + throw error; + } + } else { + try { + const requestBytes = new Uint8Array(msg.data); + const responseBytes = + await virtualServer.handleRequest(requestBytes); + const buffer = responseBytes.slice().buffer; + port.postMessage(buffer, { transfer: [buffer] }); + } catch (error) { + console.error("Error handling request in worker:", error); + throw error; + } + } + }); + + if (port !== self) { + port.start(); + } +} + +self.addEventListener("connect", bindPort); +self.addEventListener("message", bindPort); diff --git a/rust/perspective-js/src/rust/lib.rs b/rust/perspective-js/src/rust/lib.rs index e2be6ab660..89353b316e 100644 --- a/rust/perspective-js/src/rust/lib.rs +++ b/rust/perspective-js/src/rust/lib.rs @@ -26,6 +26,7 @@ extern crate alloc; mod client; +mod server; mod table; mod table_data; pub mod utils; @@ -35,6 +36,8 @@ mod view; use wasm_bindgen::prelude::*; pub use crate::client::Client; +#[cfg(target_arch = "wasm32")] +pub use crate::server::*; pub use crate::table::*; pub use crate::table_data::*; diff --git a/rust/perspective-js/src/rust/server.rs b/rust/perspective-js/src/rust/server.rs new file mode 100644 index 0000000000..27eb99e849 --- /dev/null +++ b/rust/perspective-js/src/rust/server.rs @@ -0,0 +1,759 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +#[cfg(target_arch = "wasm32")] +use std::cell::{RefCell, UnsafeCell}; +use std::future::Future; +use std::pin::Pin; +#[cfg(target_arch = "wasm32")] +use std::rc::Rc; +#[cfg(target_arch = "wasm32")] +use std::str::FromStr; +#[cfg(target_arch = "wasm32")] +use std::sync::{Arc, Mutex}; + +#[cfg(target_arch = "wasm32")] +use indexmap::IndexMap; +#[cfg(target_arch = "wasm32")] +use js_sys::{Array, Date, Object, Reflect}; +#[cfg(target_arch = "wasm32")] +use perspective_client::proto::{ColumnType, HostedTable}; +#[cfg(target_arch = "wasm32")] +use perspective_server_virtual::{ + Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerHandler, +}; +#[cfg(target_arch = "wasm32")] +use serde::Serialize; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +#[cfg(target_arch = "wasm32")] +use crate::utils::{ApiError, ApiFuture}; + +// Conditional type alias matching the trait definition +#[cfg(target_arch = "wasm32")] +type HandlerFuture = Pin>>; + +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +type HandlerFuture = Pin + Send>>; + +#[cfg(target_arch = "wasm32")] +#[derive(Debug)] +pub struct JsError(JsValue); + +#[cfg(target_arch = "wasm32")] +impl std::fmt::Display for JsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} + +#[cfg(target_arch = "wasm32")] +impl std::error::Error for JsError {} + +#[cfg(target_arch = "wasm32")] +impl From for JsError { + fn from(value: JsValue) -> Self { + JsError(value) + } +} + +#[cfg(target_arch = "wasm32")] +impl From for JsValue { + fn from(error: JsError) -> Self { + error.0 + } +} + +#[cfg(target_arch = "wasm32")] +impl From for JsError { + fn from(error: serde_wasm_bindgen::Error) -> Self { + JsError(error.into()) + } +} + +#[cfg(target_arch = "wasm32")] +// SAFETY: In WASM, we're always single-threaded, so JsError can safely be Send +// + Sync +unsafe impl Send for JsError {} + +#[cfg(target_arch = "wasm32")] +unsafe impl Sync for JsError {} + +#[cfg(target_arch = "wasm32")] +pub struct JsServerHandler(Object); + +#[cfg(target_arch = "wasm32")] +impl JsServerHandler { + fn call_method_js(&self, method: &str, args: &Array) -> Result { + let func = Reflect::get(&self.0, &JsValue::from_str(method))?; + let func = func + .dyn_ref::() + .ok_or_else(|| JsError(JsValue::from_str(&format!("{} is not a function", method))))?; + Ok(func.apply(&self.0, args)?) + } + + async fn call_method_js_async(&self, method: &str, args: &Array) -> Result { + let result = self.call_method_js(method, args)?; + + // Check if result is a Promise + if result.is_instance_of::() { + let promise = js_sys::Promise::from(result); + JsFuture::from(promise).await.map_err(|e| JsError(e)) + } else { + Ok(result) + } + } +} + +#[cfg(target_arch = "wasm32")] +impl VirtualServerHandler for JsServerHandler { + type Error = JsError; + + fn get_features(&self) -> HandlerFuture, Self::Error>> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("getFeatures")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + if !has_method { + return Box::pin(async { Ok(Features::default()) }); + } + + let handler = self.0.clone(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + let result = this.call_method_js_async("getFeatures", &args).await?; + Ok(serde_wasm_bindgen::from_value(result)?) + }) + } + + fn get_hosted_tables(&self) -> HandlerFuture, Self::Error>> { + let handler = self.0.clone(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + let result = this.call_method_js_async("getHostedTables", &args).await?; + let array = result.dyn_ref::().ok_or_else(|| { + JsError(JsValue::from_str("getHostedTables must return an array")) + })?; + + let mut tables = Vec::new(); + for i in 0..array.length() { + let item = array.get(i); + if let Some(s) = item.as_string() { + tables.push(HostedTable { + entity_id: s, + index: None, + limit: None, + }); + } else if item.is_object() { + let name = Reflect::get(&item, &JsValue::from_str("name"))? + .as_string() + .ok_or_else(|| JsError(JsValue::from_str("name must be a string")))?; + let index = Reflect::get(&item, &JsValue::from_str("index")) + .ok() + .and_then(|v| v.as_string()); + let limit = Reflect::get(&item, &JsValue::from_str("limit")) + .ok() + .and_then(|v| v.as_f64().map(|x| x as u32)); + tables.push(HostedTable { + entity_id: name, + index, + limit, + }); + } + } + Ok(tables) + }) + } + + fn table_schema( + &self, + table_id: &str, + ) -> HandlerFuture, Self::Error>> { + let handler = self.0.clone(); + let table_id = table_id.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&table_id)); + let result = this.call_method_js_async("tableSchema", &args).await?; + let obj = result + .dyn_ref::() + .ok_or_else(|| JsError(JsValue::from_str("tableSchema must return an object")))?; + + let mut schema = IndexMap::new(); + let entries = Object::entries(obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let entry_array = entry.dyn_ref::().unwrap(); + let key = entry_array.get(0).as_string().unwrap(); + let value = entry_array.get(1).as_string().unwrap(); + schema.insert(key, ColumnType::from_str(&value).unwrap()); + } + Ok(schema) + }) + } + + fn table_size(&self, table_id: &str) -> HandlerFuture> { + let handler = self.0.clone(); + let table_id = table_id.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&table_id)); + let result = this.call_method_js_async("tableSize", &args).await?; + result + .as_f64() + .map(|x| x as u32) + .ok_or_else(|| JsError(JsValue::from_str("tableSize must return a number"))) + }) + } + + fn table_validate_expression( + &self, + table_id: &str, + expression: &str, + ) -> HandlerFuture> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("tableValidateExpression")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + if !has_method { + return Box::pin(async { Ok(ColumnType::Float) }); + } + + let handler = self.0.clone(); + let table_id = table_id.to_string(); + let expression = expression.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&table_id)); + args.push(&JsValue::from_str(&expression)); + let result = this + .call_method_js_async("tableValidateExpression", &args) + .await?; + let type_str = result + .as_string() + .ok_or_else(|| JsError(JsValue::from_str("Must return a string")))?; + Ok(ColumnType::from_str(&type_str).unwrap()) + }) + } + + fn table_make_view( + &mut self, + table_id: &str, + view_id: &str, + config: &mut perspective_client::config::ViewConfigUpdate, + ) -> HandlerFuture> { + use js_sys::Object; + use js_sys::Reflect; + + let handler = self.0.clone(); + let table_id = table_id.to_string(); + let view_id = view_id.to_string(); + + // Manually construct the config object to ensure aggregates are properly serialized + let config_obj = Object::new(); + + // Serialize each field individually + if let Some(ref group_by) = config.group_by { + Reflect::set(&config_obj, &JsValue::from_str("group_by"), &serde_wasm_bindgen::to_value(group_by).unwrap()).unwrap(); + } + + if let Some(ref split_by) = config.split_by { + Reflect::set(&config_obj, &JsValue::from_str("split_by"), &serde_wasm_bindgen::to_value(split_by).unwrap()).unwrap(); + } + + if let Some(ref columns) = config.columns { + Reflect::set(&config_obj, &JsValue::from_str("columns"), &serde_wasm_bindgen::to_value(columns).unwrap()).unwrap(); + } + + if let Some(ref filter) = config.filter { + Reflect::set(&config_obj, &JsValue::from_str("filter"), &serde_wasm_bindgen::to_value(filter).unwrap()).unwrap(); + } + + if let Some(ref sort) = config.sort { + Reflect::set(&config_obj, &JsValue::from_str("sort"), &serde_wasm_bindgen::to_value(sort).unwrap()).unwrap(); + } + + if let Some(ref expressions) = config.expressions { + Reflect::set(&config_obj, &JsValue::from_str("expressions"), &serde_wasm_bindgen::to_value(&expressions.0).unwrap()).unwrap(); + } + + // Handle aggregates specially - convert Aggregate enum to simple strings + if let Some(ref aggregates) = config.aggregates { + let agg_obj = Object::new(); + for (key, agg) in aggregates.iter() { + let agg_str = match agg { + perspective_client::config::Aggregate::SingleAggregate(s) => s.clone(), + perspective_client::config::Aggregate::MultiAggregate(s, _) => s.clone(), + }; + Reflect::set(&agg_obj, &JsValue::from_str(key), &JsValue::from_str(&agg_str)).unwrap(); + } + Reflect::set(&config_obj, &JsValue::from_str("aggregates"), &agg_obj).unwrap(); + } + + let config_value = config_obj.into(); + + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&table_id)); + args.push(&JsValue::from_str(&view_id)); + args.push(&config_value); + let _ = this.call_method_js_async("tableMakeView", &args).await?; + Ok(view_id.to_string()) + }) + } + + fn table_columns_size( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> HandlerFuture> { + let handler = self.0.clone(); + let view_id = view_id.to_string(); + let config_value = serde_wasm_bindgen::to_value(config).unwrap(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + args.push(&config_value); + let result = this.call_method_js_async("tableColumnsSize", &args).await?; + result + .as_f64() + .map(|x| x as u32) + .ok_or_else(|| JsError(JsValue::from_str("tableColumnsSize must return a number"))) + }) + } + + fn view_schema( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> HandlerFuture, Self::Error>> { + let has_view_schema = Reflect::get(&self.0, &JsValue::from_str("viewSchema")) + .is_ok_and(|v| !v.is_undefined()); + + let handler = self.0.clone(); + let view_id = view_id.to_string(); + let config_value = if has_view_schema { + serde_wasm_bindgen::to_value(config).ok() + } else { + None + }; + + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + if let Some(cv) = config_value { + args.push(&cv); + } + + let result = this.call_method_js_async("viewSchema", &args).await?; + let obj = result + .dyn_ref::() + .ok_or_else(|| JsError(JsValue::from_str("viewSchema must return an object")))?; + + let mut schema = IndexMap::new(); + let entries = Object::entries(obj); + for i in 0..entries.length() { + let entry = entries.get(i); + let entry_array = entry.dyn_ref::().unwrap(); + let key = entry_array.get(0).as_string().unwrap(); + let value = entry_array.get(1).as_string().unwrap(); + schema.insert(key, ColumnType::from_str(&value).unwrap()); + } + Ok(schema) + }) + } + + fn view_size(&self, view_id: &str) -> HandlerFuture> { + let handler = self.0.clone(); + let view_id = view_id.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + let result = this.call_method_js_async("viewSize", &args).await?; + result + .as_f64() + .map(|x| x as u32) + .ok_or_else(|| JsError(JsValue::from_str("viewSize must return a number"))) + }) + } + + fn view_delete(&self, view_id: &str) -> HandlerFuture> { + let handler = self.0.clone(); + let view_id = view_id.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + this.call_method_js_async("viewDelete", &args).await?; + Ok(()) + }) + } + + fn table_make_port( + &self, + _req: &perspective_client::proto::TableMakePortReq, + ) -> HandlerFuture> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("tableMakePort")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + if !has_method { + return Box::pin(async { Ok(0) }); + } + + let handler = self.0.clone(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + let result = this.call_method_js_async("tableMakePort", &args).await?; + result + .as_f64() + .map(|x| x as u32) + .ok_or_else(|| JsError(JsValue::from_str("tableMakePort must return a number"))) + }) + } + + fn make_table( + &mut self, + table_id: &str, + data: &perspective_client::proto::MakeTableData, + ) -> HandlerFuture> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("makeTable")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + if !has_method { + return Box::pin(async { + Err(JsError(JsValue::from_str("makeTable not implemented"))) + }); + } + + let handler = self.0.clone(); + let table_id = table_id.to_string(); + + use perspective_client::proto::make_table_data::Data; + let data_value = match &data.data { + Some(Data::FromCsv(csv)) => JsValue::from_str(csv), + Some(Data::FromArrow(arrow)) => { + let uint8array = js_sys::Uint8Array::from(arrow.as_slice()); + JsValue::from(uint8array) + }, + Some(Data::FromRows(rows)) => JsValue::from_str(rows), + Some(Data::FromCols(cols)) => JsValue::from_str(cols), + Some(Data::FromNdjson(ndjson)) => JsValue::from_str(ndjson), + _ => JsValue::from_str(""), + }; + + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&table_id)); + args.push(&data_value); + this.call_method_js_async("makeTable", &args).await?; + Ok(()) + }) + } + + fn view_get_data( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + viewport: &perspective_client::proto::ViewPort, + ) -> HandlerFuture> { + let handler = self.0.clone(); + let view_id = view_id.to_string(); + let window: JsViewPort = viewport.clone().into(); + let config_value = serde_wasm_bindgen::to_value(config).unwrap(); + let window_value = serde_wasm_bindgen::to_value(&window).unwrap(); + + Box::pin(async move { + let this = JsServerHandler(handler); + let data = JsVirtualDataSlice::default(); + + { + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + args.push(&config_value); + args.push(&window_value); + args.push(&JsValue::from(data.clone())); + this.call_method_js_async("viewGetData", &args).await?; + } + + // Lock the mutex and take ownership of the inner data + // We can't unwrap the Arc because the JsValue might still hold a reference + let JsVirtualDataSlice(_obj, arc) = data; + let slice = std::mem::take(&mut *arc.lock().unwrap()); + Ok(slice) + }) + } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Serialize, PartialEq)] +pub struct JsViewPort { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_row: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_col: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_row: ::core::option::Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_col: ::core::option::Option, +} + +#[cfg(target_arch = "wasm32")] +impl From for JsViewPort { + fn from(value: perspective_client::proto::ViewPort) -> Self { + JsViewPort { + start_row: value.start_row, + start_col: value.start_col, + end_row: value.end_row, + end_col: value.end_col, + } + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +#[derive(Clone)] +pub struct JsVirtualDataSlice(Object, Arc>); + +#[cfg(target_arch = "wasm32")] +impl Default for JsVirtualDataSlice { + fn default() -> Self { + JsVirtualDataSlice( + Object::new(), + Arc::new(Mutex::new(VirtualDataSlice::default())), + ) + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +impl JsVirtualDataSlice { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } + + #[wasm_bindgen(js_name = "setCol")] + pub fn set_col( + &self, + dtype: &str, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + match dtype { + "string" => self.set_string_col(name, index, val, group_by_index), + "integer" => self.set_integer_col(name, index, val, group_by_index), + "float" => self.set_float_col(name, index, val, group_by_index), + "date" => self.set_datetime_col(name, index, val, group_by_index), + "datetime" => self.set_datetime_col(name, index, val, group_by_index), + "boolean" => self.set_boolean_col(name, index, val, group_by_index), + _ => Err(JsValue::from_str("Unknown type")), + } + } + + #[wasm_bindgen(js_name = "setStringCol")] + pub fn set_string_col( + &self, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + if val.is_null() || val.is_undefined() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Some(s) = val.as_string() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(s)) + .unwrap(); + } else { + tracing::error!("Unhandled string value"); + } + Ok(()) + } + + #[wasm_bindgen(js_name = "setIntegerCol")] + pub fn set_integer_col( + &self, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + if val.is_null() || val.is_undefined() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Some(n) = val.as_f64() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(n as i32)) + .unwrap(); + } else { + tracing::error!("Unhandled integer value"); + } + Ok(()) + } + + #[wasm_bindgen(js_name = "setFloatCol")] + pub fn set_float_col( + &self, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + if val.is_null() || val.is_undefined() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Some(n) = val.as_f64() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(n)) + .unwrap(); + } else { + tracing::error!("Unhandled float value"); + } + Ok(()) + } + + #[wasm_bindgen(js_name = "setBooleanCol")] + pub fn set_boolean_col( + &self, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + if val.is_null() || val.is_undefined() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Some(b) = val.as_bool() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(b)) + .unwrap(); + } else { + tracing::error!("Unhandled boolean value"); + } + Ok(()) + } + + #[wasm_bindgen(js_name = "setDatetimeCol")] + pub fn set_datetime_col( + &self, + name: &str, + index: u32, + val: JsValue, + group_by_index: Option, + ) -> Result<(), JsValue> { + if val.is_null() || val.is_undefined() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, None as Option) + .unwrap(); + } else if let Some(date) = val.dyn_ref::() { + let timestamp = date.get_time() as i64; + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(timestamp)) + .unwrap(); + } else if let Some(n) = val.as_f64() { + self.1 + .lock() + .unwrap() + .set_col(name, group_by_index, index as usize, Some(n as i64)) + .unwrap(); + } else { + tracing::error!("Unhandled datetime value"); + } + Ok(()) + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub struct JsVirtualServer(Rc>>); + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +impl JsVirtualServer { + #[wasm_bindgen(constructor)] + pub fn new(handler: Object) -> Result { + Ok(JsVirtualServer(Rc::new(UnsafeCell::new( + VirtualServer::new(JsServerHandler(handler)), + )))) + } + + #[wasm_bindgen(js_name = "handleRequest")] + pub fn handle_request(&self, bytes: &[u8]) -> ApiFuture> { + let bytes = bytes.to_vec(); + let server = self.0.clone(); + + ApiFuture::new(async move { + // SAFETY: + // - WASM is single-threaded + // - JS re-entrancy is allowed by design + // - VirtualServer must tolerate re-entrant mutation + let result = unsafe { + (&mut *server.as_ref().get()) + .handle_request(bytes::Bytes::from(bytes)) + .await + }; + + match result.get_internal_error() { + Ok(x) => Ok(x.to_vec()), + Err(Ok(x)) => Err(ApiError::from(JsValue::from(x))), + Err(Err(x)) => Err(ApiError::from(JsValue::from_str(&x))), + } + }) + } +} diff --git a/rust/perspective-js/src/rust/table_data.rs b/rust/perspective-js/src/rust/table_data.rs index d1d0ab0371..7987ff9925 100644 --- a/rust/perspective-js/src/rust/table_data.rs +++ b/rust/perspective-js/src/rust/table_data.rs @@ -19,7 +19,7 @@ use wasm_bindgen::intern; use wasm_bindgen::prelude::*; use crate::apierror; -use crate::utils::{ApiError, ApiResult, JsValueSerdeExt, ToApiError}; +use crate::utils::{ApiError, ApiResult, JsValueSerdeExt}; pub use crate::view::*; #[ext] @@ -28,10 +28,10 @@ impl Vec<(String, ColumnType)> { Ok(Object::keys(value.unchecked_ref()) .iter() .map(|x| -> Result<_, JsValue> { - let key = x.as_string().into_apierror()?; + let key = x.as_string().expect("Not string??"); let val = Reflect::get(value, &x)? .as_string() - .into_apierror()? + .expect("Y no string?") .into_serde_ext()?; Ok((key, val)) @@ -72,7 +72,9 @@ pub(crate) impl TableData { if all_strings() { Ok(TableData::Schema(Vec::from_js_value(value)?)) } else if all_arrays() { - let json = JSON::stringify(value)?.as_string().into_apierror()?; + let json = JSON::stringify(value)? + .as_string() + .expect("STRINGIFY_ARRAY??"); Ok(UpdateData::JsonColumns(json).into()) } else { Err(apierror!(TableError(value.clone()))) @@ -94,20 +96,20 @@ pub(crate) impl UpdateData { } else if value.is_string() { match format { None | Some(TableReadFormat::Csv) => { - Ok(Some(UpdateData::Csv(value.as_string().into_apierror()?))) + Ok(Some(UpdateData::Csv(value.as_string().expect("Csv????")))) }, Some(TableReadFormat::JsonString) => Ok(Some(UpdateData::JsonRows( - value.as_string().into_apierror()?, + value.as_string().expect("JSON???"), ))), Some(TableReadFormat::ColumnsString) => Ok(Some(UpdateData::JsonColumns( - value.as_string().into_apierror()?, + value.as_string().expect("ColumnString???"), ))), Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow( - value.as_string().into_apierror()?.into_bytes().into(), + value.as_string().expect("Arrow???").into_bytes().into(), + ))), + Some(TableReadFormat::Ndjson) => Ok(Some(UpdateData::Ndjson( + value.as_string().expect("Ndjson???"), ))), - Some(TableReadFormat::Ndjson) => { - Ok(Some(UpdateData::Ndjson(value.as_string().into_apierror()?))) - }, } } else if value.is_instance_of::() { let uint8array = Uint8Array::new(value); @@ -141,7 +143,7 @@ pub(crate) impl UpdateData { None | Some(TableReadFormat::Arrow) => Ok(Some(UpdateData::Arrow(slice.into()))), } } else if value.is_instance_of::() { - let rows = JSON::stringify(value)?.as_string().into_apierror()?; + let rows = JSON::stringify(value)?.as_string().expect("STRINGIFY??"); Ok(Some(UpdateData::JsonRows(rows))) } else { Ok(None) diff --git a/rust/perspective-js/src/rust/utils/errors.rs b/rust/perspective-js/src/rust/utils/errors.rs index 7a7fcf08c0..fca4b31d24 100644 --- a/rust/perspective-js/src/rust/utils/errors.rs +++ b/rust/perspective-js/src/rust/utils/errors.rs @@ -216,7 +216,10 @@ impl From for ApiError { impl From for ApiError { fn from(err: JsValue) -> Self { if err.is_instance_of::() { - ApiErrorType::JsRawError(err.clone().unchecked_into()).into() + ApiError( + ApiErrorType::JsRawError(err.clone().unchecked_into()), + JsBackTrace(Rc::new(err.unchecked_into())), + ) } else { apierror!(JsError(err)) } diff --git a/rust/perspective-js/src/ts/perspective.browser.ts b/rust/perspective-js/src/ts/perspective.browser.ts index be1fee9550..91d5920da4 100644 --- a/rust/perspective-js/src/ts/perspective.browser.ts +++ b/rust/perspective-js/src/ts/perspective.browser.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ export type * from "../../dist/wasm/perspective-js.d.ts"; +export * from "./virtual_server.ts"; import type * as psp from "../../dist/wasm/perspective-js.d.ts"; import * as wasm_module from "../../dist/wasm/perspective-js.js"; diff --git a/rust/perspective-js/src/ts/perspective.node.ts b/rust/perspective-js/src/ts/perspective.node.ts index 98b1c06f7d..32b1d4995b 100644 --- a/rust/perspective-js/src/ts/perspective.node.ts +++ b/rust/perspective-js/src/ts/perspective.node.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ export type * from "../../dist/wasm/perspective-js.d.ts"; +export * from "./virtual_server.ts"; import WebSocket, { WebSocketServer as HttpWebSocketServer } from "ws"; import stoppable from "stoppable"; diff --git a/rust/perspective-js/src/ts/virtual_server.ts b/rust/perspective-js/src/ts/virtual_server.ts new file mode 100644 index 0000000000..c3e94ff82b --- /dev/null +++ b/rust/perspective-js/src/ts/virtual_server.ts @@ -0,0 +1,146 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * VirtualServer API for implementing custom data sources in JavaScript/WASM. + * + * The VirtualServer pattern allows you to create custom data sources that + * integrate with Perspective's protocol. This is useful for: + * - Connecting to external databases (DuckDB, PostgreSQL, etc.) + * - Streaming data from APIs or message queues + * - Implementing custom aggregation or transformation logic + * - Creating data adapters without copying data into Perspective tables + * + * @module virtual_server + * + * @example + * ```typescript + * import { VirtualServer, VirtualDataSlice } from "@perspective-dev/client"; + * + * const handler = { + * getHostedTables: () => ["my_table"], + * tableSchema: (id: string) => ({ id: "integer", name: "string" }), + * tableSize: (id: string) => 100, + * tableMakeView: (tableId: string, viewId: string, config: any) => {}, + * tableColumnsSize: (tableId: string, config: any) => 2, + * viewSchema: (viewId: string, config?: any) => ({ id: "integer", name: "string" }), + * viewSize: (viewId: string) => 100, + * viewDelete: (viewId: string) => {}, + * viewGetData: (viewId: string, config: any, viewport: any, dataSlice: VirtualDataSlice) => { + * // Fill dataSlice with data + * dataSlice.setIntegerCol("id", 0, 1, null); + * dataSlice.setStringCol("name", 0, "Alice", null); + * } + * }; + * + * const server = new VirtualServer(handler); + * const response = server.handleRequest(requestBytes); + * ``` + */ + +export type ColumnType = + | "integer" + | "float" + | "string" + | "boolean" + | "date" + | "datetime"; + +export interface HostedTable { + name: string; + index?: string | null; + limit?: number | null; +} + +export interface ViewPort { + start_row?: number; + end_row?: number; + start_col?: number; + end_col?: number; +} + +export interface ViewConfig { + columns?: string[]; + aggregates?: Record; + group_by?: string[]; + split_by?: string[]; + sort?: Array<[string, string]>; + filter?: any[]; + expressions?: Record; +} + +export interface ServerFeatures { + expressions?: boolean; +} + +/** + * Handler interface that you implement to provide custom data sources. + * + * All methods will be called by the VirtualServer when handling protocol + * messages from Perspective clients. Methods can return values directly or + * return Promises for asynchronous operations (e.g., database queries). + * + * @example + * ```typescript + * // Synchronous handler + * const syncHandler: VirtualServerHandler = { + * getHostedTables: () => ["my_table"], + * tableSchema: (id) => ({ id: "integer", name: "string" }), + * tableSize: (id) => 100, + * // ... implement other required methods + * }; + * + * // Asynchronous handler (e.g., DuckDB WASM) + * const asyncHandler: VirtualServerHandler = { + * getHostedTables: async () => ["my_table"], + * tableSchema: async (id) => { + * const result = await db.query(`DESCRIBE ${id}`); + * return { id: "integer", name: "string" }; + * }, + * tableSize: async (id) => { + * const result = await db.query(`SELECT COUNT(*) FROM ${id}`); + * return result[0][0]; + * }, + * // ... implement other required methods + * }; + * ``` + */ +export interface VirtualServerHandler { + getHostedTables(): (string | HostedTable)[] | Promise<(string | HostedTable)[]>; + tableSchema(tableId: string): Record | Promise>; + tableSize(tableId: string): number | Promise; + tableMakeView(tableId: string, viewId: string, config: ViewConfig): void | Promise; + tableColumnsSize(tableId: string, config: ViewConfig): number | Promise; + viewSchema(viewId: string, config?: ViewConfig): Record | Promise>; + viewSize(viewId: string): number | Promise; + viewDelete(viewId: string): void | Promise; + viewGetData( + viewId: string, + config: ViewConfig, + viewport: ViewPort, + dataSlice: any, // Use 'any' here to avoid circular reference + ): void | Promise; + tableValidateExpression?(tableId: string, expression: string): ColumnType | Promise; + getFeatures?(): ServerFeatures | Promise; + makeTable?(tableId: string, data: string | Uint8Array): void | Promise; +} + +/** + * Re-export the WASM VirtualServer and VirtualDataSlice classes with better names. + * + * VirtualServer: Handles Perspective protocol messages using your custom handler + * VirtualDataSlice: Used to fill data in viewGetData callbacks + */ +export { + JsVirtualServer as VirtualServer, + JsVirtualDataSlice as VirtualDataSlice, +} from "../../dist/wasm/perspective-js.js"; diff --git a/rust/perspective-python/Cargo.toml b/rust/perspective-python/Cargo.toml index 8e9488ac6e..0600eff5d2 100644 --- a/rust/perspective-python/Cargo.toml +++ b/rust/perspective-python/Cargo.toml @@ -59,6 +59,7 @@ python-config-rs = "0.1.2" [dependencies] perspective-client = { version = "4.0.1" } perspective-server = { version = "4.0.1" } +perspective-server-virtual = { version = "4.0.1" } bytes = "1.10.1" chrono = "0.4" macro_rules_attribute = "0.2.0" diff --git a/rust/perspective-python/src/server/virtual_server_sync.rs b/rust/perspective-python/src/server/virtual_server_sync.rs index 86cbde1693..3f02b26c7d 100644 --- a/rust/perspective-python/src/server/virtual_server_sync.rs +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -16,8 +16,8 @@ use std::sync::{Arc, Mutex}; use chrono::{DateTime, TimeZone, Utc}; use indexmap::IndexMap; use perspective_client::proto::{ColumnType, HostedTable}; -use perspective_client::virtual_server::{ - Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerFuture, VirtualServerHandler, +use perspective_server_virtual::{ + Features, MaybeSendFuture, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerHandler, }; use pyo3::exceptions::PyValueError; use pyo3::types::{ @@ -31,7 +31,7 @@ pub struct PyServerHandler(Py); impl VirtualServerHandler for PyServerHandler { type Error = PyErr; - fn get_features(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { + fn get_features(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); Box::pin(async move { Python::with_gil(|py| { @@ -49,7 +49,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn get_hosted_tables(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { + fn get_hosted_tables(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); Box::pin(async move { Python::with_gil(|py| { @@ -80,7 +80,7 @@ impl VirtualServerHandler for PyServerHandler { fn table_schema( &self, table_id: &str, - ) -> VirtualServerFuture<'_, Result, Self::Error>> { + ) -> MaybeSendFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); Box::pin(async move { @@ -97,7 +97,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn table_size(&self, table_id: &str) -> VirtualServerFuture<'_, Result> { + fn table_size(&self, table_id: &str) -> MaybeSendFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); Box::pin(async move { @@ -109,33 +109,11 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn table_column_size( - &self, - table_id: &str, - ) -> VirtualServerFuture<'_, Result> { - let handler = Python::with_gil(|py| self.0.clone_ref(py)); - let table_id = table_id.to_string(); - - Box::pin(async move { - let has_table_column_size = - Python::with_gil(|py| handler.getattr(py, "table_column_size").is_ok()); - if has_table_column_size { - Python::with_gil(|py| { - handler - .call_method1(py, pyo3::intern!(py, "table_column_size"), (&table_id,))? - .extract::(py) - }) - } else { - Ok(self.table_schema(&table_id).await?.len() as u32) - } - }) - } - fn table_validate_expression( &self, table_id: &str, expression: &str, - ) -> VirtualServerFuture<'_, Result> { + ) -> MaybeSendFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); let expression = expression.to_string(); @@ -161,7 +139,7 @@ impl VirtualServerHandler for PyServerHandler { table_id: &str, view_id: &str, config: &mut perspective_client::config::ViewConfigUpdate, - ) -> VirtualServerFuture<'_, Result> { + ) -> MaybeSendFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); let view_id = view_id.to_string(); @@ -181,11 +159,32 @@ impl VirtualServerHandler for PyServerHandler { }) } + fn table_columns_size( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> MaybeSendFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let config = config.clone(); + Box::pin(async move { + Python::with_gil(|py| { + handler + .call_method1( + py, + pyo3::intern!(py, "table_columns_size"), + (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)?, + )? + .extract::(py) + }) + }) + } + fn view_schema( &self, view_id: &str, config: &perspective_client::config::ViewConfig, - ) -> VirtualServerFuture<'_, Result, Self::Error>> { + ) -> MaybeSendFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); let config = config.clone(); @@ -199,15 +198,7 @@ impl VirtualServerHandler for PyServerHandler { }; Ok(handler - .call_method1( - py, - if has_view_schema { - pyo3::intern!(py, "view_schema") - } else { - pyo3::intern!(py, "table_schema") - }, - args, - )? + .call_method1(py, pyo3::intern!(py, "view_schema"), args)? .downcast_bound::(py)? .items() .extract::>()? @@ -218,7 +209,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn view_size(&self, view_id: &str) -> VirtualServerFuture<'_, Result> { + fn view_size(&self, view_id: &str) -> MaybeSendFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); Box::pin(async move { @@ -230,34 +221,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn view_column_size( - &self, - view_id: &str, - config: &perspective_client::config::ViewConfig, - ) -> VirtualServerFuture<'_, Result> { - let handler = Python::with_gil(|py| self.0.clone_ref(py)); - let view_id = view_id.to_string(); - let config = config.clone(); - Box::pin(async move { - let has_table_column_size = - Python::with_gil(|py| handler.getattr(py, "view_column_size").is_ok()); - if has_table_column_size { - Python::with_gil(|py| { - handler - .call_method1( - py, - pyo3::intern!(py, "view_column_size"), - (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)?, - )? - .extract::(py) - }) - } else { - Ok(self.view_schema(&view_id, &config).await?.len() as u32) - } - }) - } - - fn view_delete(&self, view_id: &str) -> VirtualServerFuture<'_, Result<(), Self::Error>> { + fn view_delete(&self, view_id: &str) -> MaybeSendFuture<'_, Result<(), Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); Box::pin(async move { @@ -273,7 +237,7 @@ impl VirtualServerHandler for PyServerHandler { view_id: &str, config: &perspective_client::config::ViewConfig, viewport: &perspective_client::proto::ViewPort, - ) -> VirtualServerFuture<'_, Result> { + ) -> MaybeSendFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); let config = config.clone(); diff --git a/rust/perspective-server-virtual/Cargo.toml b/rust/perspective-server-virtual/Cargo.toml new file mode 100644 index 0000000000..15729f0a88 --- /dev/null +++ b/rust/perspective-server-virtual/Cargo.toml @@ -0,0 +1,44 @@ +# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +# ┃ Copyright (c) 2017, the Perspective Authors. ┃ +# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +# ┃ This file is part of the Perspective library, distributed under the terms ┃ +# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +[package] +name = "perspective-server-virtual" +version = "4.0.1" +authors = ["Andrew Stein "] +edition = "2024" +description = "Virtual server implementation for Perspective that delegates to user-provided handlers" +repository = "https://github.com/perspective-dev/perspective" +license = "Apache-2.0" +homepage = "https://perspective-dev.github.io" +keywords = [] + +[lib] +crate-type = ["rlib"] +path = "src/lib.rs" + +[dependencies] +perspective-client = { version = "3.8.0" } +indexmap = { version = "2.2.6", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0.107" } +thiserror = { version = "1.0.55" } +tracing = { version = ">=0.1.36", optional = true } +futures = "0.3.28" + +[dependencies.prost] +version = "0.12.3" +default-features = false +features = ["prost-derive", "std"] + +[features] +default = [] +logging = ["tracing"] diff --git a/rust/perspective-server-virtual/src/lib.rs b/rust/perspective-server-virtual/src/lib.rs new file mode 100644 index 0000000000..8e98966ee0 --- /dev/null +++ b/rust/perspective-server-virtual/src/lib.rs @@ -0,0 +1,586 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +use std::borrow::Cow; +use std::collections::HashMap; +use std::error::Error; +use std::future::Future; +use std::ops::{Deref, DerefMut}; +use std::pin::Pin; + +use indexmap::IndexMap; +use perspective_client::config::{Scalar, ViewConfig, ViewConfigUpdate}; +use perspective_client::proto::get_features_resp::{ + AggregateArgs, AggregateOptions, ColumnTypeOptions, +}; +use perspective_client::proto::response::ClientResp; +use perspective_client::proto::table_validate_expr_resp::ExprValidationError; +use perspective_client::proto::{ + ColumnType, GetFeaturesResp, GetHostedTablesResp, HostedTable, MakeTableResp, Request, Response, + TableMakePortReq, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, + TableRemoveDeleteResp, TableSchemaResp, TableSizeResp, TableValidateExprResp, + ViewColumnPathsResp, ViewDeleteResp, ViewDimensionsResp, ViewExpressionSchemaResp, + ViewGetConfigResp, ViewOnDeleteResp, ViewOnUpdateResp, ViewPort, + ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, ViewSchemaResp, ViewToColumnsStringResp, +}; +use prost::bytes::{Bytes, BytesMut}; +use prost::{DecodeError, EncodeError, Message as ProstMessage}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Error, Debug)] +pub enum VirtualServerError { + #[error("External Error: {0:?}")] + InternalError(#[from] T), + + #[error("{0}")] + DecodeError(DecodeError), + + #[error("{0}")] + EncodeError(EncodeError), + + #[error("Unknown view '{0}'")] + UnknownViewId(String), + + #[error("Invalid JSON'{0}'")] + InvalidJSON(std::sync::Arc), + + #[error("{0}")] + Other(String), +} + +pub trait ResultExt { + fn get_internal_error(self) -> Result>; +} + +impl ResultExt for Result> { + fn get_internal_error(self) -> Result> { + match self { + Ok(x) => Ok(x), + Err(VirtualServerError::InternalError(x)) => Err(Ok(x)), + Err(x) => Err(Err(x.to_string())), + } + } +} + +macro_rules! respond { + ($msg:ident, $name:ident { $($rest:tt)* }) => {{ + let mut resp = BytesMut::new(); + let resp2 = ClientResp::$name($name { + $($rest)* + }); + + Response { + msg_id: $msg.msg_id, + entity_id: $msg.entity_id, + client_resp: Some(resp2), + }.encode(&mut resp).map_err(VirtualServerError::EncodeError)?; + + resp.freeze() + }}; +} + +// Type alias for futures that conditionally includes Send bound +// WASM is single-threaded, so futures don't need to be Send +// Non-WASM targets support multi-threading, so futures must be Send +#[cfg(target_arch = "wasm32")] +pub type MaybeSendFuture<'a, T> = Pin + 'a>>; + +#[cfg(not(target_arch = "wasm32"))] +pub type MaybeSendFuture<'a, T> = Pin + Send + 'a>>; + +pub trait VirtualServerHandler { + type Error: std::error::Error + Send + Sync + 'static; + + // Required methods + fn get_hosted_tables(&self) -> MaybeSendFuture<'_, Result, Self::Error>>; + fn table_schema(&self, table_id: &str) -> MaybeSendFuture<'_, Result, Self::Error>>; + fn table_size(&self, table_id: &str) -> MaybeSendFuture<'_, Result>; + fn table_columns_size(&self, table_id: &str, config: &ViewConfig) -> MaybeSendFuture<'_, Result>; + fn table_make_view( + &mut self, + entity_id: &str, + view_id: &str, + config: &mut ViewConfigUpdate, + ) -> MaybeSendFuture<'_, Result>; + + fn view_size(&self, view_id: &str) -> MaybeSendFuture<'_, Result>; + fn view_delete(&self, view_id: &str) -> MaybeSendFuture<'_, Result<(), Self::Error>>; + fn view_schema( + &self, + entity_id: &str, + config: &ViewConfig, + ) -> MaybeSendFuture<'_, Result, Self::Error>>; + + fn view_get_data( + &self, + view_id: &str, + config: &ViewConfig, + viewport: &ViewPort, + ) -> MaybeSendFuture<'_, Result>; + + // Optional methods + fn table_validate_expression( + &self, + _table_id: &str, + _expression: &str, + ) -> MaybeSendFuture<'_, Result> { + Box::pin(async { Ok(ColumnType::Float) }) + } + + fn get_features(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { + Box::pin(async { Ok(Features::default()) }) + } + + fn table_make_port(&self, _req: &TableMakePortReq) -> MaybeSendFuture<'_, Result> { + Box::pin(async { Ok(0) }) + } + + fn make_table(&mut self, _table_id: &str, _data: &perspective_client::proto::MakeTableData) -> MaybeSendFuture<'_, Result<(), Self::Error>> { + Box::pin(async { unimplemented!("make_table not implemented") }) + } +} + +// output format +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum VirtualDataColumn { + Boolean(Vec>), + String(Vec>), + Float(Vec>), + Integer(Vec>), + Datetime(Vec>), + IntegerIndex(Vec>>), + RowPath(Vec>), +} + +pub trait SetVirtualDataColumn { + fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str>; + fn new_column() -> VirtualDataColumn; + fn to_scalar(self) -> Scalar; +} + +macro_rules! template_psp { + ($t:ty, $u:ident, $v:ident, $w:ty) => { + impl SetVirtualDataColumn for Option<$t> { + fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str> { + if let VirtualDataColumn::$u(x) = col { + x.push(self); + Ok(()) + } else { + Err("Bad type") + } + } + + fn new_column() -> VirtualDataColumn { + VirtualDataColumn::$u(vec![]) + } + + fn to_scalar(self) -> Scalar { + if let Some(x) = self { + Scalar::$v(x as $w) + } else { + Scalar::Null + } + } + } + }; +} + +template_psp!(String, String, String, String); +template_psp!(f64, Float, Float, f64); +template_psp!(i32, Integer, Float, f64); +template_psp!(i64, Datetime, Float, f64); +template_psp!(bool, Boolean, Bool, bool); + +#[derive(Debug, Default, Serialize)] +pub struct VirtualDataSlice(IndexMap); + +impl Deref for VirtualDataSlice { + type Target = IndexMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for VirtualDataSlice { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl VirtualDataSlice { + pub fn set_col( + &mut self, + name: &str, + group_by_index: Option, + index: usize, + value: T, + ) -> Result<(), Box> { + if group_by_index.is_some() { + let col = + if let Some(VirtualDataColumn::RowPath(row_path)) = self.get_mut("__ROW_PATH__") { + row_path + } else { + self.insert( + "__ROW_PATH__".to_owned(), + VirtualDataColumn::RowPath(vec![]), + ); + let Some(VirtualDataColumn::RowPath(rp)) = self.get_mut("__ROW_PATH__") else { + panic!("Irrefutable") + }; + + rp + }; + + if let Some(row) = col.get_mut(index) { + let scalar = value.to_scalar(); + row.push(scalar); + } else { + while col.len() < index { + col.push(vec![]) + } + + let scalar = value.to_scalar(); + col.push(vec![scalar]); + } + + Ok(()) + } else { + let col = if let Some(col) = self.get_mut(name) { + col + } else { + self.insert(name.to_owned(), T::new_column()); + self.get_mut(name).unwrap() + }; + + Ok(value.write_to(col)?) + } + } +} + +/// DTO for `GetFeaturesResp` +#[derive(Debug, Default, Deserialize)] +pub struct Features<'a> { + #[serde(default)] + pub group_by: bool, + + #[serde(default)] + pub split_by: bool, + + #[serde(default)] + pub filter_ops: IndexMap>>, + + #[serde(default)] + pub aggregates: IndexMap>>, + + #[serde(default)] + pub sort: bool, + + #[serde(default)] + pub expressions: bool, + + #[serde(default)] + pub on_update: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum AggSpec<'a> { + Single(Cow<'a, str>), + Multiple(Cow<'a, str>, Vec), +} + +impl<'a> From> for perspective_client::proto::GetFeaturesResp { + fn from(value: Features<'a>) -> perspective_client::proto::GetFeaturesResp { + GetFeaturesResp { + group_by: value.group_by, + split_by: value.split_by, + expressions: value.expressions, + on_update: value.on_update, + sort: value.sort, + aggregates: value + .aggregates + .iter() + .map(|(dtype, aggs)| { + (*dtype as u32, AggregateOptions { + aggregates: aggs + .iter() + .map(|agg| match agg { + AggSpec::Single(cow) => AggregateArgs { + name: cow.to_string(), + args: vec![], + }, + AggSpec::Multiple(cow, column_types) => AggregateArgs { + name: cow.to_string(), + args: column_types.iter().map(|x| *x as i32).collect(), + }, + }) + .collect(), + }) + }) + .collect(), + filter_ops: value + .filter_ops + .iter() + .map(|(ty, options)| { + (*ty as u32, ColumnTypeOptions { + options: options.iter().map(|x| (*x).to_string()).collect(), + }) + }) + .collect(), + } + } +} + +pub struct VirtualServer { + handler: T, + view_to_table: IndexMap, + view_configs: IndexMap, +} + +impl VirtualServer { + pub fn new(handler: T) -> Self { + Self { + handler, + view_configs: IndexMap::default(), + view_to_table: IndexMap::default(), + } + } + + pub async fn handle_request(&mut self, bytes: Bytes) -> Result> { + use perspective_client::proto::request::ClientReq::*; + + let msg = Request::decode(bytes).map_err(VirtualServerError::DecodeError)?; + + #[cfg(feature = "logging")] + tracing::info!("Handling request: entity_id={}, req={:?}", msg.entity_id, msg.client_req); + + let resp = match msg.client_req.unwrap() { + GetFeaturesReq(_) => { + let features = self.handler.get_features().await?; + respond!(msg, GetFeaturesResp { ..features.into() }) + }, + GetHostedTablesReq(_) => { + respond!(msg, GetHostedTablesResp { + table_infos: self.handler.get_hosted_tables().await? + }) + }, + TableSchemaReq(_) => { + respond!(msg, TableSchemaResp { + schema: self + .handler + .table_schema(msg.entity_id.as_str()).await + .ok() + .map(|value| perspective_client::proto::Schema { + schema: value + .iter() + .map(|x| perspective_client::proto::schema::KeyTypePair { + name: x.0.to_string(), + r#type: *x.1 as i32, + }) + .collect(), + }) + }) + }, + TableMakePortReq(req) => { + respond!(msg, TableMakePortResp { + port_id: self.handler.table_make_port(&req).await? + }) + }, + TableMakeViewReq(req) => { + self.view_to_table + .insert(req.view_id.clone(), msg.entity_id.clone()); + + let mut config: ViewConfigUpdate = req.config.clone().unwrap_or_default().into(); + let bytes = respond!(msg, TableMakeViewResp { + view_id: self.handler.table_make_view( + msg.entity_id.as_str(), + req.view_id.as_str(), + &mut config + ).await? + }); + + self.view_configs.insert(req.view_id.clone(), config.into()); + bytes + }, + TableSizeReq(_) => { + respond!(msg, TableSizeResp { + size: self.handler.table_size(msg.entity_id.as_str()).await? + }) + }, + TableValidateExprReq(req) => { + let mut expression_schema = HashMap::::default(); + let mut expression_alias = HashMap::::default(); + let mut errors = HashMap::::default(); + for (name, ex) in req.column_to_expr.iter() { + let _ = expression_alias.insert(name.clone(), ex.clone()); + match self + .handler + .table_validate_expression(&msg.entity_id, ex.as_str()).await + { + Ok(dtype) => { + let _ = expression_schema.insert(name.clone(), dtype as i32); + }, + Err(e) => { + let _ = errors.insert(name.clone(), ExprValidationError { + error_message: format!("{}", e), + line: 0, + column: 0, + }); + }, + } + } + + respond!(msg, TableValidateExprResp { + expression_schema, + errors, + expression_alias, + }) + }, + ViewSchemaReq(_) => { + respond!(msg, ViewSchemaResp { + schema: self + .handler + .view_schema( + msg.entity_id.as_str(), + self.view_configs.get(&msg.entity_id).unwrap() + ).await? + .into_iter() + .map(|(x, y)| (x, y as i32)) + .collect() + }) + }, + ViewDimensionsReq(_) => { + let view_id = &msg.entity_id; + let table_id = self + .view_to_table + .get(view_id) + .ok_or_else(|| VirtualServerError::UnknownViewId(view_id.to_string()))?; + + let num_table_rows = self.handler.table_size(table_id).await?; + let num_table_columns = self.handler.table_schema(table_id).await?.len() as u32; + let config = self.view_configs.get(view_id).unwrap(); + let num_view_columns = self.handler.table_columns_size(table_id, config).await?; + let num_view_rows = self.handler.view_size(view_id).await?; + let resp = ViewDimensionsResp { + num_table_columns, + num_table_rows, + num_view_columns, + num_view_rows, + }; + + respond!(msg, ViewDimensionsResp { ..resp }) + }, + ViewGetConfigReq(_) => { + respond!(msg, ViewGetConfigResp { + config: Some( + ViewConfigUpdate::from( + self.view_configs.get(&msg.entity_id).unwrap().clone() + ) + .into() + ) + }) + }, + ViewExpressionSchemaReq(_) => { + let mut schema = HashMap::::default(); + let table_id = self.view_to_table.get(&msg.entity_id); + for (name, ex) in self + .view_configs + .get(&msg.entity_id) + .unwrap() + .expressions + .iter() + { + match self + .handler + .table_validate_expression(table_id.unwrap(), ex.as_str()).await + { + Ok(dtype) => { + let _ = schema.insert(name.clone(), dtype as i32); + }, + Err(_e) => { + // TODO: handle error + }, + } + } + + let resp = ViewExpressionSchemaResp { schema }; + respond!(msg, ViewExpressionSchemaResp { ..resp }) + }, + ViewColumnPathsReq(_) => { + respond!(msg, ViewColumnPathsResp { + paths: self + .handler + .view_schema( + msg.entity_id.as_str(), + self.view_configs.get(&msg.entity_id).unwrap() + ).await? + .keys() + .cloned() + .collect() + }) + }, + ViewToColumnsStringReq(view_to_columns_string_req) => { + let viewport = view_to_columns_string_req.viewport.unwrap(); + let config = self.view_configs.get(&msg.entity_id).unwrap(); + let cols = self + .handler + .view_get_data(msg.entity_id.as_str(), config, &viewport).await?; + let json_string = serde_json::to_string(&cols) + .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?; + respond!(msg, ViewToColumnsStringResp { json_string }) + }, + ViewDeleteReq(_) => { + self.handler.view_delete(msg.entity_id.as_str()).await?; + self.view_to_table.shift_remove(&msg.entity_id); + self.view_configs.shift_remove(&msg.entity_id); + respond!(msg, ViewDeleteResp {}) + }, + MakeTableReq(req) => { + self.handler.make_table(&msg.entity_id, req.data.as_ref().unwrap()).await?; + respond!(msg, MakeTableResp {}) + }, + + // Stub implementations for callback/update requests that VirtualServer doesn't support + TableOnDeleteReq(_) => { + respond!(msg, TableOnDeleteResp {}) + }, + ViewOnUpdateReq(_) => { + respond!(msg, ViewOnUpdateResp { delta: None, port_id: 0 }) + }, + ViewOnDeleteReq(_) => { + respond!(msg, ViewOnDeleteResp {}) + }, + ViewRemoveOnUpdateReq(_) => { + respond!(msg, ViewRemoveOnUpdateResp {}) + }, + TableRemoveDeleteReq(_) => { + respond!(msg, TableRemoveDeleteResp {}) + }, + ViewRemoveDeleteReq(_) => { + respond!(msg, ViewRemoveDeleteResp {}) + }, + + x => { + #[cfg(feature = "logging")] + tracing::error!("Not handled {:?}", x); + + // Return an error response instead of empty bytes + return Err(VirtualServerError::Other(format!("Unhandled request: {:?}", x))); + }, + }; + + Ok(resp) + } +} diff --git a/rust/perspective-server/build.mjs b/rust/perspective-server/build.mjs index c188045544..31b490c687 100644 --- a/rust/perspective-server/build.mjs +++ b/rust/perspective-server/build.mjs @@ -83,10 +83,10 @@ try { fs.cpSync("build/release/web", "dist/wasm", { recursive: true }); if (!process.env.PSP_HEAP_INSTRUMENTS) { - compress( - `./dist/wasm/perspective-server.wasm`, - `./dist/wasm/perspective-server.wasm`, - ); + // compress( + // `./dist/wasm/perspective-server.wasm`, + // `./dist/wasm/perspective-server.wasm`, + // ); } } catch (e) { console.error(e); diff --git a/rust/perspective-viewer/Cargo.toml b/rust/perspective-viewer/Cargo.toml index 9fc8f6ad5d..2021a90e55 100644 --- a/rust/perspective-viewer/Cargo.toml +++ b/rust/perspective-viewer/Cargo.toml @@ -57,6 +57,8 @@ async-lock = "2.5.0" # Encode HTML export base64 = "0.13.0" +console_error_panic_hook = "0.1.6" + # Timezone correction chrono = "0.4" diff --git a/rust/perspective-viewer/src/rust/lib.rs b/rust/perspective-viewer/src/rust/lib.rs index a38e093d8e..afe67c05cc 100644 --- a/rust/perspective-viewer/src/rust/lib.rs +++ b/rust/perspective-viewer/src/rust/lib.rs @@ -91,6 +91,7 @@ pub fn registerPlugin(name: &str) { #[cfg(not(feature = "external-bootstrap"))] #[wasm_bindgen(js_name = "init")] pub fn js_init() { + console_error_panic_hook::set_once(); perspective_js::utils::set_global_logging(); define_web_components!("export * as psp from '../../perspective-viewer.js'"); tracing::info!("Perspective initialized."); diff --git a/rust/perspective-viewer/src/rust/session.rs b/rust/perspective-viewer/src/rust/session.rs index dd5ef3711e..88be0c4f7f 100644 --- a/rust/perspective-viewer/src/rust/session.rs +++ b/rust/perspective-viewer/src/rust/session.rs @@ -822,11 +822,16 @@ impl ValidSession<'_> { .0 .metadata() .get_column_aggregates(col.as_str()) - .into_apierror()? - .next() - .into_apierror()?; - - let _ = view_config.aggregates.insert(col.to_string(), agg); + .and_then(|mut aggs| aggs.next()) + .into_apierror(); + + match agg { + Err(_) => tracing::warn!( + "No default aggregate for column '{}' found, skipping", + col + ), + Ok(agg) => _ = view_config.aggregates.insert(col.to_string(), agg), + }; } } From 87bf5aaf940cfea7b83459dcc2362cb186a76259 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Sun, 25 Jan 2026 16:48:56 -0500 Subject: [PATCH 4/4] Refactoring, tests, examples, docs, cleanup Signed-off-by: Andrew Stein --- .github/workflows/build.yaml | 22 +- Cargo.lock | 3 + Cargo.toml | 2 - README.md | 41 +- examples/README.md | 7 + examples/blocks/examples.js | 1 + examples/blocks/package.json | 2 +- examples/blocks/src/duckdb/README.md | 15 + examples/blocks/src/duckdb/index.css | 0 examples/blocks/src/duckdb/index.html | 40 + examples/blocks/src/duckdb/index.js | 99 +++ .../build.js | 16 +- .../package.json | 4 +- .../server.mjs | 9 +- .../src/index.html | 0 .../src/index.ts} | 101 +-- packages/react/test/js/react.spec.tsx | 17 +- packages/react/test/js/workspace.story.tsx | 5 +- .../src/less/regular_table.less | 39 +- .../src/ts/style_handlers/body.ts | 2 + .../test/js/superstore.spec.js | 3 +- packages/workspace/src/less/viewer.less | 2 +- packages/workspace/src/themes/pro-dark.less | 2 +- packages/workspace/src/themes/pro.less | 2 +- .../workspace/src/ts/workspace/workspace.ts | 4 +- pnpm-lock.yaml | 80 +- pnpm-workspace.yaml | 4 +- rust/bundle/main.rs | 19 +- .../src/rust/virtual_server/features.rs | 3 +- rust/perspective-js/Cargo.toml | 2 + rust/perspective-js/build.mjs | 8 + rust/perspective-js/package.json | 3 + rust/perspective-js/src/rust/client.rs | 6 +- rust/perspective-js/src/rust/lib.rs | 7 +- .../src/rust/{server.rs => virtual_server.rs} | 207 +++-- .../src/ts/perspective-server.worker.ts | 56 +- .../src/ts/perspective.browser.ts | 20 +- .../perspective-js/src/ts/perspective.node.ts | 58 +- rust/perspective-js/src/ts/virtual_server.ts | 160 ++-- .../src/ts/virtual_servers/duckdb.ts | 321 +++----- rust/perspective-js/src/ts/wasm/browser.ts | 26 +- rust/perspective-js/test/js/duckdb.spec.js | 735 ++++++++++++++++++ rust/perspective-js/tsconfig.browser.json | 3 +- rust/perspective-js/tsconfig.json | 1 + rust/perspective-python/Cargo.toml | 1 - .../src/server/virtual_server_sync.rs | 104 ++- rust/perspective-server-virtual/Cargo.toml | 44 -- rust/perspective-server-virtual/src/lib.rs | 586 -------------- rust/perspective-server/build.mjs | 8 +- .../src/rust/components/viewer.rs | 11 +- tools/test/results.tar.gz | Bin 159495 -> 159494 bytes 51 files changed, 1598 insertions(+), 1313 deletions(-) create mode 100644 examples/blocks/src/duckdb/README.md create mode 100644 examples/blocks/src/duckdb/index.css create mode 100644 examples/blocks/src/duckdb/index.html create mode 100644 examples/blocks/src/duckdb/index.js rename examples/{nodejs-virtual-server => esbuild-duckdb-virtual}/build.js (88%) rename examples/{nodejs-virtual-server => esbuild-duckdb-virtual}/package.json (89%) rename examples/{nodejs-virtual-server => esbuild-duckdb-virtual}/server.mjs (86%) rename examples/{nodejs-virtual-server => esbuild-duckdb-virtual}/src/index.html (100%) rename examples/{nodejs-virtual-server/src/index.js => esbuild-duckdb-virtual/src/index.ts} (58%) rename rust/perspective-js/src/rust/{server.rs => virtual_server.rs} (86%) rename examples/nodejs-virtual-server/src/worker.js => rust/perspective-js/src/ts/virtual_servers/duckdb.ts (65%) create mode 100644 rust/perspective-js/test/js/duckdb.spec.js delete mode 100644 rust/perspective-server-virtual/Cargo.toml delete mode 100644 rust/perspective-server-virtual/src/lib.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 43f896e8ad..e9c4fa4ace 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -73,7 +73,7 @@ jobs: - ubuntu-22.04 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] steps: - name: Checkout @@ -125,7 +125,7 @@ jobs: - ubuntu-22.04 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] steps: - name: Checkout @@ -193,7 +193,7 @@ jobs: - x86_64 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] include: - os: ubuntu-22.04 arch: x86_64 @@ -305,7 +305,7 @@ jobs: - windows-2022 arch: - x86_64 - node-version: [20.x] + node-version: [22.x] is-release: - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' }} exclude: @@ -395,7 +395,7 @@ jobs: - x86_64 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] steps: - name: Checkout uses: actions/checkout@v4 @@ -443,7 +443,7 @@ jobs: - x86_64 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] steps: - name: Checkout uses: actions/checkout@v4 @@ -510,7 +510,7 @@ jobs: - x86_64 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] is-release: - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' }} exclude: @@ -621,7 +621,7 @@ jobs: - ubuntu-22.04 python-version: - 3.9 - node-version: [20.x] + node-version: [22.x] steps: - name: Checkout @@ -674,7 +674,7 @@ jobs: python-version: - 3.9 # - 3.12 - node-version: [20.x] + node-version: [22.x] is-release: - ${{ startsWith(github.ref, 'refs/tags/v') || github.ref_name == 'master' }} exclude: @@ -823,7 +823,7 @@ jobs: matrix: os: [ubuntu-22.04] python-version: [3.9] - node-version: [20.x] + node-version: [22.x] arch: [x86_64] steps: - name: Checkout @@ -880,7 +880,7 @@ jobs: strategy: matrix: os: [ubuntu-22.04] - node-version: [20.x] + node-version: [22.x] runs-on: ${{ matrix.os }} steps: - name: Checkout diff --git a/Cargo.lock b/Cargo.lock index 8e4e03acba..80815092ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1787,11 +1787,13 @@ name = "perspective-js" version = "4.0.1" dependencies = [ "anyhow", + "bytes", "chrono", "derivative", "extend", "futures", "getrandom 0.2.16", + "indexmap 2.12.1", "js-sys", "perspective-client", "prost", @@ -1872,6 +1874,7 @@ dependencies = [ "async-lock", "base64 0.13.1", "chrono", + "console_error_panic_hook", "derivative", "extend", "futures", diff --git a/Cargo.toml b/Cargo.toml index 52cbf1306a..fb3c21bd1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ members = [ "rust/perspective-js", "rust/perspective-python", "rust/perspective-server", - "rust/perspective-server-virtual", "rust/perspective-viewer", "examples/rust-axum", ] @@ -50,7 +49,6 @@ strip = true # simd-adler32 = { git = "https://github.com/mcountryman/simd-adler32.git", rev = "b279034d9eb554c3e5e0af523db044f08d8297ba" } perspective-client = { path = "rust/perspective-client" } perspective-server = { path = "rust/perspective-server" } -perspective-server-virtual = { path = "rust/perspective-server-virtual" } perspective-js = { path = "rust/perspective-js" } perspective = { path = "rust/perspective" } perspective-viewer = { path = "rust/perspective-viewer" } diff --git a/README.md b/README.md index 35327ac707..52cccc9771 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@
+ @@ -14,27 +15,33 @@
-Perspective is an interactive analytics and data visualization component, -which is especially well-suited for large and/or streaming -datasets. Use it to create user-configurable reports, dashboards, notebooks and -applications. +Perspective is an interactive analytics and data visualization component for +large and streaming datasets. Build user-configurable reports, dashboards, +notebooks, and applications with a high-performance query engine compiled to +WebAssembly, Python, and Rust. ### Features -- A fast, memory efficient streaming query engine, written in C++ and compiled - for [WebAssembly](https://webassembly.org/), [Python](https://www.python.org/) - and [Rust](https://www.rust-lang.org/), with read/write/streaming for - [Apache Arrow](https://arrow.apache.org/), and a high-performance columnar - expression language based on [ExprTK](https://github.com/ArashPartow/exprtk). - -- A framework-agnostic User Interface packaged as a +- A framework-agnostic user interface packaged as a [Custom Element](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements), - powered either in-browser via WebAssembly or virtually via WebSocket server - (Python/Node/Rust). + which connects to a Data Model in-browser (via WebAssembly) or remotely (via + WebSocket, with integration in Python, Node.js and Rust). Includes a data + grid, 10+ chart types line, bar, area, scatter, heatmap, treemap, sunburst, + candlestick, and more. + +- A Data Model API for pluggable engines, enabling Perspective's UI to query + external data sources like [DuckDB](https://duckdb.org/) while translating + view configurations into native queries. + +- A fast, memory-efficient streaming Data Model built-in, written in C++ and + compiled for [WebAssembly](https://webassembly.org/), + [Python](https://www.python.org/), and [Rust](https://www.rust-lang.org/). + Supports read/write/streaming for [Apache Arrow](https://arrow.apache.org/), + with a columnar expression language based on + [ExprTK](https://github.com/ArashPartow/exprtk). -- A [JupyterLab](https://jupyter.org/) widget and Python client library, for - interactive data analysis in a notebook, as well as _scalable_ production - applications. +- A [JupyterLab](https://jupyter.org/) widget and Python client library for + interactive data analysis in notebooks. ### Documentation @@ -62,7 +69,7 @@ applications. ### Examples -
editablefilefractal
marketraycastingevictions
nypdstreamingcovid
webcammoviessuperstore
citibikeolympicsdataset
+
editablefileduckdb
fractalmarketraycasting
evictionsnypdstreaming
covidwebcammovies
superstorecitibikeolympics
dataset
### Media diff --git a/examples/README.md b/examples/README.md index 8bb2dc5609..6c4174a689 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,13 @@ In order to _run_ a project in this directory as written: 2. Run the project with `pnpm run start $PROJECT_NAME` from the repository root (_not_ the `/examples` directory). +## VirtualServer Examples + +These examples demonstrate custom data source implementations using the VirtualServer API, which allows you to create backends that serve data from any source without loading it into Perspective's tables: + +- **[nodejs-virtual-server](nodejs-virtual-server/)** - Node.js example with in-memory data and WebSocket server +- **[python-duckdb-virtual](python-duckdb-virtual/)** - Python example using DuckDB as a data source + # Optional Generally, the changes necessary to make these examples run _without_ the diff --git a/examples/blocks/examples.js b/examples/blocks/examples.js index 1a2c3a6b85..7ab7b2bfb1 100644 --- a/examples/blocks/examples.js +++ b/examples/blocks/examples.js @@ -13,6 +13,7 @@ const LOCAL_EXAMPLES = [ "editable", "file", + "duckdb", "fractal", "market", "raycasting", diff --git a/examples/blocks/package.json b/examples/blocks/package.json index f5b8e438aa..a6bc3ad5d4 100644 --- a/examples/blocks/package.json +++ b/examples/blocks/package.json @@ -4,7 +4,7 @@ "version": "4.0.1", "description": "A collection of simple client-side Perspective examples for `http://bl.ocks.org`.", "scripts": { - "start": "mkdir -p dist && node --experimental-wasm-memory64 --experimental-modules server.mjs", + "start": "mkdir -p dist && node --experimental-modules server.mjs", "repl": "node --experimental-repl-await" }, "main": "index.mjs", diff --git a/examples/blocks/src/duckdb/README.md b/examples/blocks/src/duckdb/README.md new file mode 100644 index 0000000000..41d5291f70 --- /dev/null +++ b/examples/blocks/src/duckdb/README.md @@ -0,0 +1,15 @@ +An example of [Perspective](https://github.com/perspective-dev/perspective) +using [DuckDB WASM](https://duckdb.org/docs/api/wasm/overview) as a virtual +server backend via the `DuckDBHandler` adapter. + +Instead of using Perspective's built-in WebAssembly query engine, this example +demonstrates how to use DuckDB as the data processing layer while still +leveraging Perspective's visualization components. The `DuckDBHandler` translates +Perspective's view configuration (group by, split by, sort, filter, expressions, +aggregates) into DuckDB SQL queries, enabling Perspective to query data stored +in DuckDB tables. + +This example loads the Superstore sample dataset into a DuckDB table, then +creates a Perspective viewer that queries the data through the DuckDB virtual +server. A separate log viewer displays the SQL queries being generated in +real-time, along with a timeline chart showing query frequency. diff --git a/examples/blocks/src/duckdb/index.css b/examples/blocks/src/duckdb/index.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/blocks/src/duckdb/index.html b/examples/blocks/src/duckdb/index.html new file mode 100644 index 0000000000..f266a8e523 --- /dev/null +++ b/examples/blocks/src/duckdb/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/blocks/src/duckdb/index.js b/examples/blocks/src/duckdb/index.js new file mode 100644 index 0000000000..e35d56aa5c --- /dev/null +++ b/examples/blocks/src/duckdb/index.js @@ -0,0 +1,99 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import "/node_modules/@perspective-dev/viewer/dist/cdn/perspective-viewer.js"; +import "/node_modules/@perspective-dev/viewer-datagrid/dist/cdn/perspective-viewer-datagrid.js"; +import "/node_modules/@perspective-dev/viewer-d3fc/dist/cdn/perspective-viewer-d3fc.js"; + +import perspective from "/node_modules/@perspective-dev/client/dist/cdn/perspective.js"; +import { DuckDBHandler } from "/node_modules/@perspective-dev/client/dist/esm/virtual_servers/duckdb.js"; + +// Need to use jsDelivr's ESM features to load this as packaged. +import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.33.1-dev18.0/+esm"; + +const LOGGER = { + log(entry) { + table2.update([{ timestamp: entry.timestamp, sql: entry.value }]); + }, +}; + +const db = await initializeDuckDB(); +const server = perspective.createMessageHandler(new DuckDBHandler(db)); +const client = await perspective.worker(server); + +const logworker = await perspective.worker(); +const table2 = await logworker.table( + { timestamp: "datetime", sql: "string" }, + { name: "logs", limit: 10_000 }, +); + +const log_element = document.querySelector("#logger"); +log_element.load(logworker); +log_element.restore({ + table: "logs", + sort: [["timestamp", "desc"]], + title: "SQL Log", +}); + +const log_element2 = document.querySelector("#logger2"); +log_element2.load(logworker); +log_element2.restore({ + table: "logs", + sort: [["timestamp", "desc"]], + columns: ["sql"], + group_by: ["1s"], + plugin: "Y Bar", + expressions: { "1s": `bucket("timestamp",'1s')` }, + title: "SQL Timeline", +}); + +async function initializeDuckDB() { + const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); + const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); + const worker_url = URL.createObjectURL( + new Blob([`importScripts("${bundle.mainWorker}");`], { + type: "text/javascript", + }), + ); + + const duckdb_worker = new Worker(worker_url); + const db = new duckdb.AsyncDuckDB(LOGGER, duckdb_worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + URL.revokeObjectURL(worker_url); + const conn = await db.connect(); + return conn; +} + +async function loadSampleData(db) { + const response = await fetch( + "/node_modules/superstore-arrow/superstore.lz4.arrow", + ); + + const text = await response.arrayBuffer(); + await db.insertArrowFromIPCStream(new Uint8Array(text), { + name: "data_source_one", + create: true, + }); +} + +await loadSampleData(db); + +const viewer = document.querySelector("#query"); +viewer.load(client); +viewer.restore({ + table: "data_source_one", + group_by: ["Region", "State", "City"], + columns: ["Sales", "Profit", "Quantity", "Discount"], + plugin: "Datagrid", + theme: "Pro Dark", + settings: true, +}); diff --git a/examples/nodejs-virtual-server/build.js b/examples/esbuild-duckdb-virtual/build.js similarity index 88% rename from examples/nodejs-virtual-server/build.js rename to examples/esbuild-duckdb-virtual/build.js index a4fc58728d..4bae075f3c 100644 --- a/examples/nodejs-virtual-server/build.js +++ b/examples/esbuild-duckdb-virtual/build.js @@ -20,21 +20,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); async function build() { await esbuild.build({ - entryPoints: ["src/index.js"], - outdir: "dist", - format: "esm", - bundle: true, - sourcemap: "inline", - target: "es2022", - loader: { - ".ttf": "file", - ".wasm": "file", - }, - assetNames: "[name]", - }); - - await esbuild.build({ - entryPoints: ["src/worker.js"], + entryPoints: ["src/index.ts"], outdir: "dist", format: "esm", bundle: true, diff --git a/examples/nodejs-virtual-server/package.json b/examples/esbuild-duckdb-virtual/package.json similarity index 89% rename from examples/nodejs-virtual-server/package.json rename to examples/esbuild-duckdb-virtual/package.json index 95e9cecdfb..ce7c2bf428 100644 --- a/examples/nodejs-virtual-server/package.json +++ b/examples/esbuild-duckdb-virtual/package.json @@ -1,5 +1,5 @@ { - "name": "nodejs-virtual-server", + "name": "esbuild-duckdb-virtual", "private": true, "version": "3.8.0", "type": "module", @@ -16,7 +16,7 @@ "@perspective-dev/viewer": "workspace:^", "@perspective-dev/viewer-d3fc": "workspace:^", "@perspective-dev/viewer-datagrid": "workspace:^", - "@duckdb/duckdb-wasm": "^1.30.0", + "@duckdb/duckdb-wasm": "catalog:", "superstore-arrow": "catalog:" }, "devDependencies": { diff --git a/examples/nodejs-virtual-server/server.mjs b/examples/esbuild-duckdb-virtual/server.mjs similarity index 86% rename from examples/nodejs-virtual-server/server.mjs rename to examples/esbuild-duckdb-virtual/server.mjs index 7311d243a3..49418e5c61 100644 --- a/examples/nodejs-virtual-server/server.mjs +++ b/examples/esbuild-duckdb-virtual/server.mjs @@ -10,13 +10,7 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -/** - * Example of using VirtualServer in a Web Worker - * - * This demonstrates how to create a custom data source using the VirtualServer API - * running client-side in a Web Worker. The VirtualServer implementation is in worker.js, - * and this server simply serves the static files. - */ +// This is just a file server, the implementation is in `src/index.js`. import http from "http"; import { fileURLToPath } from "url"; @@ -32,5 +26,4 @@ const httpServer = http.createServer((req, res) => httpServer.listen(8080, () => { console.log("Server listening on http://localhost:8080"); - console.log("Open your browser to see the VirtualServer running in a Web Worker!"); }); diff --git a/examples/nodejs-virtual-server/src/index.html b/examples/esbuild-duckdb-virtual/src/index.html similarity index 100% rename from examples/nodejs-virtual-server/src/index.html rename to examples/esbuild-duckdb-virtual/src/index.html diff --git a/examples/nodejs-virtual-server/src/index.js b/examples/esbuild-duckdb-virtual/src/index.ts similarity index 58% rename from examples/nodejs-virtual-server/src/index.js rename to examples/esbuild-duckdb-virtual/src/index.ts index c9f9aec5d7..baa7646209 100644 --- a/examples/nodejs-virtual-server/src/index.js +++ b/examples/esbuild-duckdb-virtual/src/index.ts @@ -10,8 +10,6 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -window.queueMicrotask = undefined; - import perspective from "@perspective-dev/client"; import perspective_viewer from "@perspective-dev/viewer"; import "@perspective-dev/viewer-datagrid"; @@ -20,66 +18,69 @@ import "@perspective-dev/viewer-d3fc"; import "@perspective-dev/viewer/dist/css/themes.css"; import "@perspective-dev/viewer/dist/css/pro.css"; +// @ts-ignore import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; + +// @ts-ignore import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; +import { DuckDBHandler } from "@perspective-dev/client/dist/esm/virtual_servers/duckdb.js"; +import * as duckdb from "@duckdb/duckdb-wasm"; + +// @ts-ignore +import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow"; + await Promise.all([ perspective.init_server(fetch(SERVER_WASM)), perspective_viewer.init_client(fetch(CLIENT_WASM)), ]); -await customElements.whenDefined("perspective-viewer"); +async function initializeDuckDB() { + const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); + const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); + const worker_url = URL.createObjectURL( + new Blob([`importScripts("${bundle.mainWorker}");`], { + type: "text/javascript", + }), + ); -// Create a worker that hosts the VirtualServer -const worker = new Worker(new URL("./worker.js", import.meta.url), { - type: "module", -}); + const duckdb_worker = new Worker(worker_url); + const logger = new duckdb.VoidLogger(); + const db = new duckdb.AsyncDuckDB(logger, duckdb_worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + URL.revokeObjectURL(worker_url); + const conn = await db.connect(); + await conn.query(` + SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; + `); + + console.log("DuckDB initialized"); + return conn; +} -const client = await perspective.worker(Promise.resolve(worker)); +async function loadSampleData(db: duckdb.AsyncDuckDBConnection) { + // const c = await db.connect(); + try { + const response = await fetch(SUPERSTORE_ARROW); + const arrayBuffer = await response.arrayBuffer(); + await db.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { + name: "data_source_one", + create: true, + }); + } catch (error) { + console.error("Error loading Arrow data:", error); + } +} -const table = await client.open_table("data_source_one"); +const db = await initializeDuckDB(); +await perspective.init_client(fetch(CLIENT_WASM)); +await loadSampleData(db); +const server = perspective.createMessageHandler(new DuckDBHandler(db)); +const client = await perspective.worker(server); -const viewer = document.querySelector("perspective-viewer"); -await viewer.load(table); +const viewer = document.querySelector("perspective-viewer")!; +viewer.load(client); viewer.restore({ - version: "3.8.0", - plugin: "Datagrid", - plugin_config: { - columns: {}, - edit_mode: "READ_ONLY", - scroll_lock: false, - }, - columns_config: {}, - settings: false, - theme: "Pro Light", - title: null, + table: "data_source_one", group_by: ["State"], - split_by: [], - sort: [], - filter: [], - expressions: {}, - columns: [ - "Row ID", - "Order ID", - "Order Date", - "Ship Date", - "Ship Mode", - "Customer ID", - "Customer Name", - "Segment", - "Country", - "City", - // "State", - "Postal Code", - "Region", - "Product ID", - "Category", - "Sub-Category", - "Product Name", - "Sales", - "Quantity", - "Discount", - "Profit", - ], - aggregates: {}, }); diff --git a/packages/react/test/js/react.spec.tsx b/packages/react/test/js/react.spec.tsx index 57dd2c6348..41db762eb1 100644 --- a/packages/react/test/js/react.spec.tsx +++ b/packages/react/test/js/react.spec.tsx @@ -15,22 +15,6 @@ import { test, expect } from "@playwright/experimental-ct-react"; import { App } from "./basic.story"; import { EmptyWorkspace, SingleView } from "./workspace.story"; -async function retryUntilSuccess( - fn: () => Promise, - { maxAttempts = 5, delay = 1000 } = {}, -): Promise { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const r = await fn(); - if (r) { - return true; - } - } catch {} - await new Promise((r) => setTimeout(r, delay)); - } - return false; -} - test.describe("Perspective React", () => { test("The viewer loads with data in it", async ({ page, mount }) => { const comp = await mount(); @@ -57,6 +41,7 @@ test.describe("Perspective React", () => { document.querySelector("perspective-workspace")!.children .length === 3, ); + await expect(viewer).toHaveCount(3); await toggleMount.click(); await workspace.waitFor({ state: "detached" }); diff --git a/packages/react/test/js/workspace.story.tsx b/packages/react/test/js/workspace.story.tsx index 55f7be831d..4074de77a6 100644 --- a/packages/react/test/js/workspace.story.tsx +++ b/packages/react/test/js/workspace.story.tsx @@ -61,11 +61,10 @@ const WorkspaceApp: React.FC = (props) => { mounted: true, }); - const onClickAddViewer = async () => { + const onClickAddViewer = () => { const name = window.crypto.randomUUID(); const data = `a,b,c\n${Math.random()},${Math.random()},${Math.random()}`; - const t = await CLIENT.table(data, { name }); - console.log(await t.get_name()); + CLIENT.table(data, { name }); const nextId = Workspace.genId(state.layout); const layout = Workspace.addViewer( state.layout, diff --git a/packages/viewer-datagrid/src/less/regular_table.less b/packages/viewer-datagrid/src/less/regular_table.less index 7a50886164..67604bf94d 100644 --- a/packages/viewer-datagrid/src/less/regular_table.less +++ b/packages/viewer-datagrid/src/less/regular_table.less @@ -27,22 +27,25 @@ mask-size: cover; } -perspective-viewer:not([settings]) { - @include settings-not-open; -} - -:host-context(perspective-viewer:not([settings])) { - @include settings-not-open; -} - -@mixin settings-not-open { - regular-table table tr.rt-autosize + tr th { - height: 0px; - span { - display: none; - } - } -} +// // TODO this makes the UI flash a CSS layout for a millsiecond when toggling +// // settings butit could be fixed. + +// perspective-viewer:not([settings]) { +// @include settings-not-open; +// } + +// :host-context(perspective-viewer:not([settings])) { +// @include settings-not-open; +// } + +// @mixin settings-not-open { +// regular-table table tr.rt-autosize + tr th { +// height: 0px; +// span { +// display: none; +// } +// } +// } @mixin settings-open { .psp-menu-enabled { @@ -89,11 +92,11 @@ perspective-viewer:not([settings]) { } } -perspective-viewer[settings] { +perspective-viewer { @include settings-open; } -:host-context(perspective-viewer[settings]) { +:host { @include settings-open; } diff --git a/packages/viewer-datagrid/src/ts/style_handlers/body.ts b/packages/viewer-datagrid/src/ts/style_handlers/body.ts index cab30f35ad..6c479401f3 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/body.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/body.ts @@ -117,6 +117,8 @@ export function applyBodyCellStyles( "psp-menu-open", column_name === this._column_settings_selected_column, ); + } else { + td.classList.toggle("psp-menu-open", false); } td.classList.toggle( diff --git a/packages/viewer-datagrid/test/js/superstore.spec.js b/packages/viewer-datagrid/test/js/superstore.spec.js index fac74ef000..2e00eed91a 100644 --- a/packages/viewer-datagrid/test/js/superstore.spec.js +++ b/packages/viewer-datagrid/test/js/superstore.spec.js @@ -192,11 +192,12 @@ test.describe("Datagrid with superstore data set", () => { await td.click(); await td.asElement().fill("Test"); await page.evaluate(() => document.activeElement.blur()); - const result = await page.evaluate(async () => { + await document.querySelector("perspective-viewer").flush(); const view = await document .querySelector("perspective-viewer") .getView(); + const json = await view.to_json_string({ end_row: 4 }); return json; }); diff --git a/packages/workspace/src/less/viewer.less b/packages/workspace/src/less/viewer.less index e2de711ad8..da4463c516 100644 --- a/packages/workspace/src/less/viewer.less +++ b/packages/workspace/src/less/viewer.less @@ -97,7 +97,7 @@ border: 1px solid red; } -::slotted(perspective-viewer:not([settings])) { +::slotted(perspective-viewer:not(.widget-maximize)) { --status-bar--padding: 0 36px 0 8px; } diff --git a/packages/workspace/src/themes/pro-dark.less b/packages/workspace/src/themes/pro-dark.less index 396ffc5b82..7571e72d27 100644 --- a/packages/workspace/src/themes/pro-dark.less +++ b/packages/workspace/src/themes/pro-dark.less @@ -27,7 +27,7 @@ perspective-workspace perspective-viewer { --plugin-selector--height: 47px; } -perspective-workspace perspective-viewer[settings] { +perspective-workspace perspective-viewer.widget-maximize { --modal-panel--margin: -4px 0 -4px 0; --status-bar--border-radius: 6px 0 0 0; --main-column--margin: 3px 0 3px 3px; diff --git a/packages/workspace/src/themes/pro.less b/packages/workspace/src/themes/pro.less index 2e64c1d07e..29008f31d9 100644 --- a/packages/workspace/src/themes/pro.less +++ b/packages/workspace/src/themes/pro.less @@ -28,7 +28,7 @@ perspective-workspace { background-color: #dadada; } -perspective-workspace perspective-viewer[settings] { +perspective-workspace perspective-viewer.widget-maximize { --modal-panel--margin: -4px 0 -4px 0; --status-bar--border-radius: 6px 0 0 0; --main-column--margin: 3px 0 3px 3px; diff --git a/packages/workspace/src/ts/workspace/workspace.ts b/packages/workspace/src/ts/workspace/workspace.ts index e184c8045d..7d33165714 100644 --- a/packages/workspace/src/ts/workspace/workspace.ts +++ b/packages/workspace/src/ts/workspace/workspace.ts @@ -1034,7 +1034,7 @@ export class PerspectiveWorkspace extends SplitPanel { for (const client of this.client) { const tables = await client.get_hosted_table_names(); - if (tables.indexOf(table) > -1) { + if (table && tables.indexOf(table) > -1) { await viewer.load(client); return await this._createWidget({ config, @@ -1166,7 +1166,7 @@ export class PerspectiveWorkspace extends SplitPanel { ); widget.viewer.addEventListener( - "perspective-toggle-settings-before", + "perspective-toggle-settings", settings_after, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3555750899..2b3c4fd0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@d3fc/d3fc-element': specifier: 6.2.0 version: 6.2.0 + '@duckdb/duckdb-wasm': + specifier: ^1.30.0 + version: 1.32.0 '@fontsource/roboto-mono': specifier: 4.5.10 version: 4.5.10 @@ -151,8 +154,8 @@ catalogs: specifier: ^18 version: 18.3.1 regular-table: - specifier: '=0.7.2' - version: 0.7.2 + specifier: '=0.7.3' + version: 0.7.3 stoppable: specifier: '=1.1.0' version: 1.1.0 @@ -177,6 +180,9 @@ catalogs: vite: specifier: '>=6 <7' version: 6.4.1 + web-worker: + specifier: 1.4.1 + version: 1.4.1 webpack: specifier: '>=5 <6' version: 5.102.1 @@ -364,6 +370,34 @@ importers: specifier: 'catalog:' version: 0.25.11 + examples/esbuild-duckdb-virtual: + dependencies: + '@duckdb/duckdb-wasm': + specifier: 'catalog:' + version: 1.32.0 + '@perspective-dev/client': + specifier: workspace:^ + version: link:../../rust/perspective-js + '@perspective-dev/server': + specifier: workspace:^ + version: link:../../rust/perspective-server + '@perspective-dev/viewer': + specifier: workspace:^ + version: link:../../rust/perspective-viewer + '@perspective-dev/viewer-d3fc': + specifier: workspace:^ + version: link:../../packages/viewer-d3fc + '@perspective-dev/viewer-datagrid': + specifier: workspace:^ + version: link:../../packages/viewer-datagrid + superstore-arrow: + specifier: 'catalog:' + version: 3.2.0 + devDependencies: + esbuild: + specifier: 'catalog:' + version: 0.25.11 + examples/esbuild-example: dependencies: '@perspective-dev/client': @@ -779,7 +813,7 @@ importers: version: 3.1.2 regular-table: specifier: 'catalog:' - version: 0.7.2 + version: 0.7.3 devDependencies: '@perspective-dev/esbuild-plugin': specifier: 'workspace:' @@ -911,6 +945,9 @@ importers: specifier: 'catalog:' version: 8.18.3 devDependencies: + '@duckdb/duckdb-wasm': + specifier: 'catalog:' + version: 1.32.0 '@perspective-dev/esbuild-plugin': specifier: 'workspace:' version: link:../../tools/esbuild-plugin @@ -953,6 +990,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + web-worker: + specifier: 'catalog:' + version: 1.4.1 zx: specifier: 'catalog:' version: 8.8.5 @@ -2461,6 +2501,9 @@ packages: resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} engines: {node: '>=20.0'} + '@duckdb/duckdb-wasm@1.32.0': + resolution: {integrity: sha512-IewXTNYEjsZCPE9weUWgtjGxUlMRo7qhX0GF6tq/KjK8bnY+RAl4cyUdYUfcdzbyb4b9ZxPC+FOsCcxgaKFWMg==} + '@esbuild/aix-ppc64@0.25.11': resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} engines: {node: '>=18'} @@ -4016,6 +4059,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apache-arrow@17.0.0: + resolution: {integrity: sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==} + hasBin: true + apache-arrow@18.1.0: resolution: {integrity: sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==} hasBin: true @@ -7747,8 +7794,8 @@ packages: resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} hasBin: true - regular-table@0.7.2: - resolution: {integrity: sha512-IyAlxssZF6TGPh620Sjym6/7DH1pUPLFp816P4/0xFovU4oXi+40p5wRfTOQxlvIz73Sz6pb90hvKcrDsPnBBw==} + regular-table@0.7.3: + resolution: {integrity: sha512-u0JgzFO/E2BDCQomvnREk2V8oa6E4PNK3KmWUB6lk1wK3v/1MrwEicdBkSZC9mwElQ1lMw0vbh8vn8J9/mdRtA==} engines: {node: '>=16'} rehype-raw@7.0.0: @@ -8708,6 +8755,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-worker@1.4.1: + resolution: {integrity: sha512-bEYkHEaeUTjiWscVoW7UBLmAV1S9v0AELr9+3B94Ps1G6E5N/jmSth1e5RZoWbLZWqkI/eyb7KT3sto0ugRpLg==} + webdriver-bidi-protocol@0.3.8: resolution: {integrity: sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==} @@ -11095,6 +11145,10 @@ snapshots: - uglify-js - webpack-cli + '@duckdb/duckdb-wasm@1.32.0': + dependencies: + apache-arrow: 17.0.0 + '@esbuild/aix-ppc64@0.25.11': optional: true @@ -13228,6 +13282,18 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + apache-arrow@17.0.0: + dependencies: + '@swc/helpers': 0.5.17 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 20.19.23 + command-line-args: 5.2.1 + command-line-usage: 7.0.3 + flatbuffers: 24.12.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + apache-arrow@18.1.0: dependencies: '@swc/helpers': 0.5.17 @@ -17661,7 +17727,7 @@ snapshots: dependencies: jsesc: 3.1.0 - regular-table@0.7.2: {} + regular-table@0.7.3: {} rehype-raw@7.0.0: dependencies: @@ -18758,6 +18824,8 @@ snapshots: web-namespaces@2.0.1: {} + web-worker@1.4.1: {} + webdriver-bidi-protocol@0.3.8: {} webidl-conversions@3.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e0c7daefb..1e95bb7563 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,11 +39,12 @@ catalog: "pro_self_extracting_wasm": "0.0.9" "react-dom": "^18" "react": "^18" - "regular-table": "=0.7.2" + "regular-table": "=0.7.3" "stoppable": "=1.1.0" "ws": "^8.17.0" # Dev Dependencies + "@duckdb/duckdb-wasm": "^1.30.0" "@fontsource/roboto-mono": "4.5.10" "@iarna/toml": "3.0.0" "@jupyterlab/builder": "^4" @@ -95,4 +96,5 @@ catalog: "vite": ">=6 <7" "webpack-cli": ">=5 <6" "webpack": ">=5 <6" + "web-worker": "1.4.1" "zx": ">=8 <9" diff --git a/rust/bundle/main.rs b/rust/bundle/main.rs index b6e779f398..28beb8a6aa 100644 --- a/rust/bundle/main.rs +++ b/rust/bundle/main.rs @@ -39,13 +39,24 @@ use wasm_opt::{Feature, OptimizationOptions}; /// field to the host platform. fn build(pkg: Option<&str>, is_release: bool, features: Vec) { let features = format!("tracing/release_max_level_warn,{}", features.join(",")); + + // Build RUSTFLAGS including target-specific flags from config.toml and new + // panic flags These are the flags from .cargo/config.toml for + // wasm32-unknown-unknown target + let target_flags = [ + "--cfg=getrandom_backend=\"wasm_js\"", + "--cfg=web_sys_unstable_apis", + "-Ctarget-feature=+bulk-memory,+simd128,+relaxed-simd,+reference-types", + ]; + + let rustflags = target_flags.join(" "); let mut cmd = Command::new("cargo"); - cmd.args(["build"]) + cmd.env("RUSTFLAGS", rustflags) + .args(["build"]) .args(["--lib"]) .args(["--features", &features]) - .args(["--target", "wasm32-unknown-unknown"]) - .args(["-Z", "build-std=std,panic_abort"]) - .args(["-Z", "build-std-features=panic_immediate_abort"]); + .args(["--target", "wasm32-unknown-unknown"]); + // .args(["-Z", "build-std=std"]); if is_release { cmd.args(["--release"]); diff --git a/rust/perspective-client/src/rust/virtual_server/features.rs b/rust/perspective-client/src/rust/virtual_server/features.rs index 5766bf6630..902ff354fc 100644 --- a/rust/perspective-client/src/rust/virtual_server/features.rs +++ b/rust/perspective-client/src/rust/virtual_server/features.rs @@ -20,7 +20,8 @@ use crate::proto::{ColumnType, GetFeaturesResp}; /// Describes the capabilities supported by a virtual server handler. /// -/// This struct is returned by [`VirtualServerHandler::get_features`](super::VirtualServerHandler::get_features) +/// This struct is returned by +/// [`VirtualServerHandler::get_features`](super::VirtualServerHandler::get_features) /// to inform clients about which operations are available. #[derive(Debug, Default, Deserialize)] pub struct Features<'a> { diff --git a/rust/perspective-js/Cargo.toml b/rust/perspective-js/Cargo.toml index bb01a758f3..9b97a931f6 100644 --- a/rust/perspective-js/Cargo.toml +++ b/rust/perspective-js/Cargo.toml @@ -64,11 +64,13 @@ wasm-bindgen-test = "0.3.13" [dependencies] perspective-client = { version = "4.0.1" } +bytes = "1.10.1" chrono = "0.4" derivative = "2.2.0" extend = "1.1.2" futures = "0.3.28" getrandom = { version = "0.2", features = ["js"] } +indexmap = "2.2.6" js-sys = "0.3.77" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.107", features = ["raw_value"] } diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs index 9bd250c25e..a8281e456a 100644 --- a/rust/perspective-js/build.mjs +++ b/rust/perspective-js/build.mjs @@ -70,6 +70,13 @@ const BUILD = [ // "Load as binary": true, // "Bundler friendly": true, // }, + { + entryPoints: ["src/ts/virtual_servers/duckdb.ts"], + format: "esm", + target: "es2022", + plugins: [PerspectiveEsbuildPlugin()], + outfile: "dist/esm/virtual_servers/duckdb.js", + }, { entryPoints: ["src/ts/perspective.browser.ts"], format: "esm", @@ -133,6 +140,7 @@ async function build_all() { try { await $`tsc --project ./tsconfig.browser.json`; await $`tsc --project ./tsconfig.node.json`; + // await $`tsc --project ./tsconfig.duckdb.json`; } catch (e) { console.error(e.stdout); console.error(e.stderr); diff --git a/rust/perspective-js/package.json b/rust/perspective-js/package.json index fb809cbe55..750d0e2170 100644 --- a/rust/perspective-js/package.json +++ b/rust/perspective-js/package.json @@ -23,6 +23,7 @@ "types": "./dist/esm/perspective.node.d.ts", "default": "./dist/esm/perspective.node.js" }, + "./virtual_servers/*": "./dist/esm/virtual_servers/*", "./dist/*": "./dist/*", "./src/*": "./src/*", "./test/*": "./test/*", @@ -52,6 +53,7 @@ "@perspective-dev/esbuild-plugin": "workspace:", "@perspective-dev/metadata": "workspace:", "@perspective-dev/test": "workspace:", + "@duckdb/duckdb-wasm": "catalog:", "@playwright/experimental-ct-react": "catalog:", "@playwright/test": "catalog:", "@types/node": "catalog:", @@ -63,6 +65,7 @@ "moment": "catalog:", "typedoc": "catalog:", "typescript": "catalog:", + "web-worker": "catalog:", "zx": "catalog:" } } diff --git a/rust/perspective-js/src/rust/client.rs b/rust/perspective-js/src/rust/client.rs index d8a09ebd6a..4ae7b16dbf 100644 --- a/rust/perspective-js/src/rust/client.rs +++ b/rust/perspective-js/src/rust/client.rs @@ -425,10 +425,8 @@ impl Client { /// const tables = await client.get_hosted_table_names(); /// ``` #[wasm_bindgen] - pub async fn get_hosted_table_names(&self) -> ApiResult { - Ok(JsValue::from_serde_ext( - &self.client.get_hosted_table_names().await?, - )?) + pub async fn get_hosted_table_names(&self) -> ApiResult> { + Ok(self.client.get_hosted_table_names().await?) } /// Register a callback which is invoked whenever [`Client::table`] (on this diff --git a/rust/perspective-js/src/rust/lib.rs b/rust/perspective-js/src/rust/lib.rs index 89353b316e..891be28f82 100644 --- a/rust/perspective-js/src/rust/lib.rs +++ b/rust/perspective-js/src/rust/lib.rs @@ -26,20 +26,21 @@ extern crate alloc; mod client; -mod server; mod table; mod table_data; pub mod utils; mod view; +#[cfg(target_arch = "wasm32")] +mod virtual_server; #[cfg(feature = "export-init")] use wasm_bindgen::prelude::*; pub use crate::client::Client; -#[cfg(target_arch = "wasm32")] -pub use crate::server::*; pub use crate::table::*; pub use crate::table_data::*; +#[cfg(target_arch = "wasm32")] +pub use crate::virtual_server::*; #[cfg(feature = "export-init")] #[wasm_bindgen(typescript_custom_section)] diff --git a/rust/perspective-js/src/rust/server.rs b/rust/perspective-js/src/rust/virtual_server.rs similarity index 86% rename from rust/perspective-js/src/rust/server.rs rename to rust/perspective-js/src/rust/virtual_server.rs index 27eb99e849..7f5bb78328 100644 --- a/rust/perspective-js/src/rust/server.rs +++ b/rust/perspective-js/src/rust/virtual_server.rs @@ -10,92 +10,71 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -#[cfg(target_arch = "wasm32")] -use std::cell::{RefCell, UnsafeCell}; +use std::cell::UnsafeCell; use std::future::Future; use std::pin::Pin; -#[cfg(target_arch = "wasm32")] use std::rc::Rc; -#[cfg(target_arch = "wasm32")] use std::str::FromStr; -#[cfg(target_arch = "wasm32")] use std::sync::{Arc, Mutex}; -#[cfg(target_arch = "wasm32")] use indexmap::IndexMap; -#[cfg(target_arch = "wasm32")] use js_sys::{Array, Date, Object, Reflect}; -#[cfg(target_arch = "wasm32")] use perspective_client::proto::{ColumnType, HostedTable}; -#[cfg(target_arch = "wasm32")] -use perspective_server_virtual::{ +use perspective_client::virtual_server::{ Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerHandler, }; -#[cfg(target_arch = "wasm32")] use serde::Serialize; -#[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; -#[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::JsFuture; -#[cfg(target_arch = "wasm32")] -use crate::utils::{ApiError, ApiFuture}; +use crate::utils::{ApiError, ApiFuture, *}; // Conditional type alias matching the trait definition #[cfg(target_arch = "wasm32")] type HandlerFuture = Pin>>; #[cfg(not(target_arch = "wasm32"))] -#[allow(dead_code)] type HandlerFuture = Pin + Send>>; -#[cfg(target_arch = "wasm32")] #[derive(Debug)] pub struct JsError(JsValue); -#[cfg(target_arch = "wasm32")] impl std::fmt::Display for JsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}", self.0) } } -#[cfg(target_arch = "wasm32")] impl std::error::Error for JsError {} -#[cfg(target_arch = "wasm32")] impl From for JsError { fn from(value: JsValue) -> Self { JsError(value) } } -#[cfg(target_arch = "wasm32")] impl From for JsValue { fn from(error: JsError) -> Self { error.0 } } -#[cfg(target_arch = "wasm32")] impl From for JsError { fn from(error: serde_wasm_bindgen::Error) -> Self { JsError(error.into()) } } -#[cfg(target_arch = "wasm32")] // SAFETY: In WASM, we're always single-threaded, so JsError can safely be Send // + Sync unsafe impl Send for JsError {} - -#[cfg(target_arch = "wasm32")] unsafe impl Sync for JsError {} -#[cfg(target_arch = "wasm32")] pub struct JsServerHandler(Object); -#[cfg(target_arch = "wasm32")] +unsafe impl Send for JsServerHandler {} +unsafe impl Sync for JsServerHandler {} + impl JsServerHandler { fn call_method_js(&self, method: &str, args: &Array) -> Result { let func = Reflect::get(&self.0, &JsValue::from_str(method))?; @@ -118,7 +97,6 @@ impl JsServerHandler { } } -#[cfg(target_arch = "wasm32")] impl VirtualServerHandler for JsServerHandler { type Error = JsError; @@ -223,23 +201,51 @@ impl VirtualServerHandler for JsServerHandler { }) } + fn table_column_size(&self, view_id: &str) -> HandlerFuture> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("tableColumnsSize")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + let handler = self.0.clone(); + let view_id = view_id.to_string(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + if has_method { + let result = this.call_method_js_async("tableColumnsSize", &args).await?; + result.as_f64().map(|x| x as u32).ok_or_else(|| { + JsError(JsValue::from_str( + "tableColumnsSize must + return a number", + )) + }) + } else { + Ok(this.table_schema(view_id.as_str()).await?.len() as u32) + } + }) + } + fn table_validate_expression( &self, table_id: &str, expression: &str, ) -> HandlerFuture> { + // TODO Cache these inspection calls let has_method = Reflect::get(&self.0, &JsValue::from_str("tableValidateExpression")) .map(|val| !val.is_undefined()) .unwrap_or(false); - if !has_method { - return Box::pin(async { Ok(ColumnType::Float) }); - } - let handler = self.0.clone(); let table_id = table_id.to_string(); let expression = expression.to_string(); Box::pin(async move { + if !has_method { + return Err(JsError(JsValue::from_str( + "feature `table_validate_expression` not implemented", + ))); + } + let this = JsServerHandler(handler); let args = Array::new(); args.push(&JsValue::from_str(&table_id)); @@ -247,9 +253,11 @@ impl VirtualServerHandler for JsServerHandler { let result = this .call_method_js_async("tableValidateExpression", &args) .await?; + let type_str = result .as_string() .ok_or_else(|| JsError(JsValue::from_str("Must return a string")))?; + Ok(ColumnType::from_str(&type_str).unwrap()) }) } @@ -260,88 +268,21 @@ impl VirtualServerHandler for JsServerHandler { view_id: &str, config: &mut perspective_client::config::ViewConfigUpdate, ) -> HandlerFuture> { - use js_sys::Object; - use js_sys::Reflect; - let handler = self.0.clone(); let table_id = table_id.to_string(); let view_id = view_id.to_string(); - - // Manually construct the config object to ensure aggregates are properly serialized - let config_obj = Object::new(); - - // Serialize each field individually - if let Some(ref group_by) = config.group_by { - Reflect::set(&config_obj, &JsValue::from_str("group_by"), &serde_wasm_bindgen::to_value(group_by).unwrap()).unwrap(); - } - - if let Some(ref split_by) = config.split_by { - Reflect::set(&config_obj, &JsValue::from_str("split_by"), &serde_wasm_bindgen::to_value(split_by).unwrap()).unwrap(); - } - - if let Some(ref columns) = config.columns { - Reflect::set(&config_obj, &JsValue::from_str("columns"), &serde_wasm_bindgen::to_value(columns).unwrap()).unwrap(); - } - - if let Some(ref filter) = config.filter { - Reflect::set(&config_obj, &JsValue::from_str("filter"), &serde_wasm_bindgen::to_value(filter).unwrap()).unwrap(); - } - - if let Some(ref sort) = config.sort { - Reflect::set(&config_obj, &JsValue::from_str("sort"), &serde_wasm_bindgen::to_value(sort).unwrap()).unwrap(); - } - - if let Some(ref expressions) = config.expressions { - Reflect::set(&config_obj, &JsValue::from_str("expressions"), &serde_wasm_bindgen::to_value(&expressions.0).unwrap()).unwrap(); - } - - // Handle aggregates specially - convert Aggregate enum to simple strings - if let Some(ref aggregates) = config.aggregates { - let agg_obj = Object::new(); - for (key, agg) in aggregates.iter() { - let agg_str = match agg { - perspective_client::config::Aggregate::SingleAggregate(s) => s.clone(), - perspective_client::config::Aggregate::MultiAggregate(s, _) => s.clone(), - }; - Reflect::set(&agg_obj, &JsValue::from_str(key), &JsValue::from_str(&agg_str)).unwrap(); - } - Reflect::set(&config_obj, &JsValue::from_str("aggregates"), &agg_obj).unwrap(); - } - - let config_value = config_obj.into(); - + let config = config.clone(); Box::pin(async move { let this = JsServerHandler(handler); let args = Array::new(); args.push(&JsValue::from_str(&table_id)); args.push(&JsValue::from_str(&view_id)); - args.push(&config_value); + args.push(&JsValue::from_serde_ext(&config)?); let _ = this.call_method_js_async("tableMakeView", &args).await?; Ok(view_id.to_string()) }) } - fn table_columns_size( - &self, - view_id: &str, - config: &perspective_client::config::ViewConfig, - ) -> HandlerFuture> { - let handler = self.0.clone(); - let view_id = view_id.to_string(); - let config_value = serde_wasm_bindgen::to_value(config).unwrap(); - Box::pin(async move { - let this = JsServerHandler(handler); - let args = Array::new(); - args.push(&JsValue::from_str(&view_id)); - args.push(&config_value); - let result = this.call_method_js_async("tableColumnsSize", &args).await?; - result - .as_f64() - .map(|x| x as u32) - .ok_or_else(|| JsError(JsValue::from_str("tableColumnsSize must return a number"))) - }) - } - fn view_schema( &self, view_id: &str, @@ -366,7 +307,17 @@ impl VirtualServerHandler for JsServerHandler { args.push(&cv); } - let result = this.call_method_js_async("viewSchema", &args).await?; + let result = this + .call_method_js_async( + if has_view_schema { + "viewSchema" + } else { + "tableSchema" + }, + &args, + ) + .await?; + let obj = result .dyn_ref::() .ok_or_else(|| JsError(JsValue::from_str("viewSchema must return an object")))?; @@ -380,6 +331,7 @@ impl VirtualServerHandler for JsServerHandler { let value = entry_array.get(1).as_string().unwrap(); schema.insert(key, ColumnType::from_str(&value).unwrap()); } + Ok(schema) }) } @@ -387,11 +339,24 @@ impl VirtualServerHandler for JsServerHandler { fn view_size(&self, view_id: &str) -> HandlerFuture> { let handler = self.0.clone(); let view_id = view_id.to_string(); + let has_view_size = + Reflect::get(&self.0, &JsValue::from_str("viewSize")).is_ok_and(|v| !v.is_undefined()); + Box::pin(async move { let this = JsServerHandler(handler); let args = Array::new(); args.push(&JsValue::from_str(&view_id)); - let result = this.call_method_js_async("viewSize", &args).await?; + let result = this + .call_method_js_async( + if has_view_size { + "viewSize" + } else { + "tableSize" + }, + &args, + ) + .await?; + result .as_f64() .map(|x| x as u32) @@ -399,6 +364,35 @@ impl VirtualServerHandler for JsServerHandler { }) } + fn view_column_size( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> HandlerFuture> { + let has_method = Reflect::get(&self.0, &JsValue::from_str("viewColumnSize")) + .map(|val| !val.is_undefined()) + .unwrap_or(false); + + let handler = self.0.clone(); + let view_id = view_id.to_string(); + let config_value = serde_wasm_bindgen::to_value(config).unwrap(); + let config = config.clone(); + Box::pin(async move { + let this = JsServerHandler(handler); + let args = Array::new(); + args.push(&JsValue::from_str(&view_id)); + args.push(&config_value); + if has_method { + let result = this.call_method_js_async("viewColumnSize", &args).await?; + result.as_f64().map(|x| x as u32).ok_or_else(|| { + JsError(JsValue::from_str("viewColumnSize must return a number")) + }) + } else { + Ok(this.view_schema(view_id.as_str(), &config).await?.len() as u32) + } + }) + } + fn view_delete(&self, view_id: &str) -> HandlerFuture> { let handler = self.0.clone(); let view_id = view_id.to_string(); @@ -510,7 +504,6 @@ impl VirtualServerHandler for JsServerHandler { } } -#[cfg(target_arch = "wasm32")] #[derive(Serialize, PartialEq)] pub struct JsViewPort { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -526,7 +519,6 @@ pub struct JsViewPort { pub end_col: ::core::option::Option, } -#[cfg(target_arch = "wasm32")] impl From for JsViewPort { fn from(value: perspective_client::proto::ViewPort) -> Self { JsViewPort { @@ -538,12 +530,10 @@ impl From for JsViewPort { } } -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] #[derive(Clone)] pub struct JsVirtualDataSlice(Object, Arc>); -#[cfg(target_arch = "wasm32")] impl Default for JsVirtualDataSlice { fn default() -> Self { JsVirtualDataSlice( @@ -553,7 +543,6 @@ impl Default for JsVirtualDataSlice { } } -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl JsVirtualDataSlice { #[wasm_bindgen(constructor)] @@ -719,11 +708,9 @@ impl JsVirtualDataSlice { } } -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] pub struct JsVirtualServer(Rc>>); -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl JsVirtualServer { #[wasm_bindgen(constructor)] diff --git a/rust/perspective-js/src/ts/perspective-server.worker.ts b/rust/perspective-js/src/ts/perspective-server.worker.ts index 61a862767e..2aebe2d845 100644 --- a/rust/perspective-js/src/ts/perspective-server.worker.ts +++ b/rust/perspective-js/src/ts/perspective-server.worker.ts @@ -20,35 +20,45 @@ import { compile_perspective } from "./wasm/emscripten_api.ts"; let GLOBAL_SERVER: PerspectiveServer; let POLL_THREAD: PerspectivePollThread; -function bindPort(e: MessageEvent) { - const port = e.ports[0]; - let session: PerspectiveSession; - port.addEventListener("message", async (msg) => { - if (msg.data.cmd === "init") { - const id = msg.data.id; - if (!GLOBAL_SERVER) { - const module = await compile_perspective(msg.data.args[0]); - GLOBAL_SERVER = new PerspectiveServer(module, { - on_poll_request: () => POLL_THREAD.on_poll_request(), - }); - - POLL_THREAD = new PerspectivePollThread(GLOBAL_SERVER); - } - - session = GLOBAL_SERVER.make_session(async (resp) => { - const f = resp.slice().buffer; - port.postMessage(f, { transfer: [f] }); +let SESSION: PerspectiveSession | undefined; + +async function handleMessage(this: MessagePort, msg: MessageEvent) { + if (msg.data.cmd === "init") { + const id = msg.data.id; + if (!GLOBAL_SERVER) { + const module = await compile_perspective(msg.data.args[0]); + + GLOBAL_SERVER = new PerspectiveServer(module, { + on_poll_request: () => POLL_THREAD.on_poll_request(), }); - port.postMessage({ id }); + POLL_THREAD = new PerspectivePollThread(GLOBAL_SERVER); + } + + SESSION = GLOBAL_SERVER.make_session(async (resp) => { + const f = resp.slice().buffer; + this.postMessage(f, { transfer: [f] }); + }); + + this.postMessage({ id }); + } else { + if (SESSION) { + await SESSION?.handle_request(new Uint8Array(msg.data)); } else { - await session.handle_request(new Uint8Array(msg.data)); + throw new Error("No session"); } - }); + } +} +function bindPortSharedWorker(msg: MessageEvent) { + const port = msg.ports[0]; + port.addEventListener("message", handleMessage.bind(port)); port.start(); } // @ts-expect-error wrong scope -self.addEventListener("connect", bindPort); -self.addEventListener("message", bindPort); +self.addEventListener("connect", bindPortSharedWorker); +self.addEventListener( + "message", + handleMessage.bind(self as unknown as MessagePort), +); diff --git a/rust/perspective-js/src/ts/perspective.browser.ts b/rust/perspective-js/src/ts/perspective.browser.ts index 91d5920da4..5278e96278 100644 --- a/rust/perspective-js/src/ts/perspective.browser.ts +++ b/rust/perspective-js/src/ts/perspective.browser.ts @@ -11,8 +11,10 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ export type * from "../../dist/wasm/perspective-js.d.ts"; -export * from "./virtual_server.ts"; import type * as psp from "../../dist/wasm/perspective-js.d.ts"; +export type * from "./virtual_server.ts"; + +import * as psp_virtual from "./virtual_server.ts"; import * as wasm_module from "../../dist/wasm/perspective-js.js"; import * as api from "./wasm/browser.ts"; @@ -20,6 +22,12 @@ import { load_wasm_stage_0 } from "./wasm/decompress.ts"; let GLOBAL_SERVER_WASM: Promise; +export async function createMessageHandler( + handler: psp_virtual.VirtualServerHandler, +) { + return psp_virtual.createMessageHandler(await get_client(), handler); +} + export function init_server( wasm: | Uint8Array @@ -121,7 +129,7 @@ export async function websocket(url: string | URL) { } export async function worker( - worker?: Promise, + worker?: Promise, ) { if (typeof worker === "undefined") { worker = get_worker(); @@ -130,4 +138,10 @@ export async function worker( return await api.worker(get_client(), get_server(), worker); } -export default { websocket, worker, init_client, init_server }; +export default { + websocket, + worker, + init_client, + init_server, + createMessageHandler, +}; diff --git a/rust/perspective-js/src/ts/perspective.node.ts b/rust/perspective-js/src/ts/perspective.node.ts index 32b1d4995b..aa23f90c05 100644 --- a/rust/perspective-js/src/ts/perspective.node.ts +++ b/rust/perspective-js/src/ts/perspective.node.ts @@ -11,7 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ export type * from "../../dist/wasm/perspective-js.d.ts"; -export * from "./virtual_server.ts"; +export type * from "./virtual_server.ts"; import WebSocket, { WebSocketServer as HttpWebSocketServer } from "ws"; import stoppable from "stoppable"; @@ -28,6 +28,9 @@ import { load_wasm_stage_0 } from "./wasm/decompress.js"; import * as engine from "./wasm/engine.ts"; import { compile_perspective } from "./wasm/emscripten_api.ts"; import * as psp_websocket from "./websocket.ts"; +import * as api from "./wasm/browser.ts"; + +import * as virtual_server from "./virtual_server.ts"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); @@ -178,17 +181,6 @@ function buffer_to_arraybuffer( } } -function invert_promise(): [(t: T) => void, Promise, (t: any) => void] { - let sender: ((t: T) => void) | undefined = undefined, - reject = undefined; - let receiver: Promise = new Promise((x, u) => { - sender = x; - reject = u; - }); - - return [sender!, receiver, reject!]; -} - export class WebSocketServer { _server: http.Server | any; // stoppable has no type ... _wss: HttpWebSocketServer; @@ -304,9 +296,51 @@ export async function websocket( ); } +export async function worker(worker: Promise) { + const port = await worker; + const client = new perspective_client.Client( + async (proto: Uint8Array) => { + const f = proto.slice().buffer; + port.postMessage(f, { transfer: [f] }); + }, + async () => { + console.debug("Closing WebWorker"); + port.close(); + }, + ); + + const { promise, resolve, reject } = Promise.withResolvers(); + port.onmessage = function listener(resp) { + port.onmessage = null; + resolve(null); + }; + + port.onmessageerror = function (...args) { + port.onmessage = null; + console.error(...args); + reject(args); + }; + + port.postMessage({ cmd: "init", args: [] }); + await promise; + port.addEventListener("message", (json: MessageEvent) => { + client.handle_response(json.data); + }); + + console.log(client); + return client; +} + +export function createMessageHandler( + handler: virtual_server.VirtualServerHandler, +) { + return virtual_server.createMessageHandler(perspective_client, handler); +} + export default { table, websocket, + worker, get_hosted_table_names, on_hosted_tables_update, remove_hosted_tables_update, diff --git a/rust/perspective-js/src/ts/virtual_server.ts b/rust/perspective-js/src/ts/virtual_server.ts index c3e94ff82b..b33b300ae4 100644 --- a/rust/perspective-js/src/ts/virtual_server.ts +++ b/rust/perspective-js/src/ts/virtual_server.ts @@ -10,6 +10,12 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import { ColumnType } from "./ts-rs/ColumnType.ts"; +import { ViewConfig } from "./ts-rs/ViewConfig.ts"; +import { ViewWindow } from "./ts-rs/ViewWindow.ts"; + +import type * as perspective from "../../dist/wasm/perspective-js.js"; + /** * VirtualServer API for implementing custom data sources in JavaScript/WASM. * @@ -21,63 +27,8 @@ * - Creating data adapters without copying data into Perspective tables * * @module virtual_server - * - * @example - * ```typescript - * import { VirtualServer, VirtualDataSlice } from "@perspective-dev/client"; - * - * const handler = { - * getHostedTables: () => ["my_table"], - * tableSchema: (id: string) => ({ id: "integer", name: "string" }), - * tableSize: (id: string) => 100, - * tableMakeView: (tableId: string, viewId: string, config: any) => {}, - * tableColumnsSize: (tableId: string, config: any) => 2, - * viewSchema: (viewId: string, config?: any) => ({ id: "integer", name: "string" }), - * viewSize: (viewId: string) => 100, - * viewDelete: (viewId: string) => {}, - * viewGetData: (viewId: string, config: any, viewport: any, dataSlice: VirtualDataSlice) => { - * // Fill dataSlice with data - * dataSlice.setIntegerCol("id", 0, 1, null); - * dataSlice.setStringCol("name", 0, "Alice", null); - * } - * }; - * - * const server = new VirtualServer(handler); - * const response = server.handleRequest(requestBytes); - * ``` */ -export type ColumnType = - | "integer" - | "float" - | "string" - | "boolean" - | "date" - | "datetime"; - -export interface HostedTable { - name: string; - index?: string | null; - limit?: number | null; -} - -export interface ViewPort { - start_row?: number; - end_row?: number; - start_col?: number; - end_col?: number; -} - -export interface ViewConfig { - columns?: string[]; - aggregates?: Record; - group_by?: string[]; - split_by?: string[]; - sort?: Array<[string, string]>; - filter?: any[]; - expressions?: Record; -} - export interface ServerFeatures { expressions?: boolean; } @@ -88,50 +39,79 @@ export interface ServerFeatures { * All methods will be called by the VirtualServer when handling protocol * messages from Perspective clients. Methods can return values directly or * return Promises for asynchronous operations (e.g., database queries). - * - * @example - * ```typescript - * // Synchronous handler - * const syncHandler: VirtualServerHandler = { - * getHostedTables: () => ["my_table"], - * tableSchema: (id) => ({ id: "integer", name: "string" }), - * tableSize: (id) => 100, - * // ... implement other required methods - * }; - * - * // Asynchronous handler (e.g., DuckDB WASM) - * const asyncHandler: VirtualServerHandler = { - * getHostedTables: async () => ["my_table"], - * tableSchema: async (id) => { - * const result = await db.query(`DESCRIBE ${id}`); - * return { id: "integer", name: "string" }; - * }, - * tableSize: async (id) => { - * const result = await db.query(`SELECT COUNT(*) FROM ${id}`); - * return result[0][0]; - * }, - * // ... implement other required methods - * }; - * ``` */ export interface VirtualServerHandler { - getHostedTables(): (string | HostedTable)[] | Promise<(string | HostedTable)[]>; - tableSchema(tableId: string): Record | Promise>; + getHostedTables(): string[] | Promise; + tableSchema( + tableId: string, + ): Record | Promise>; tableSize(tableId: string): number | Promise; - tableMakeView(tableId: string, viewId: string, config: ViewConfig): void | Promise; - tableColumnsSize(tableId: string, config: ViewConfig): number | Promise; - viewSchema(viewId: string, config?: ViewConfig): Record | Promise>; - viewSize(viewId: string): number | Promise; + tableMakeView( + tableId: string, + viewId: string, + config: ViewConfig, + ): void | Promise; viewDelete(viewId: string): void | Promise; viewGetData( viewId: string, config: ViewConfig, - viewport: ViewPort, - dataSlice: any, // Use 'any' here to avoid circular reference + viewport: ViewWindow, + dataSlice: perspective.JsVirtualDataSlice, ): void | Promise; - tableValidateExpression?(tableId: string, expression: string): ColumnType | Promise; + viewSchema?( + viewId: string, + config?: ViewConfig, + ): Record | Promise>; + viewSize?(viewId: string): number | Promise; + tableValidateExpression?( + tableId: string, + expression: string, + ): ColumnType | Promise; getFeatures?(): ServerFeatures | Promise; - makeTable?(tableId: string, data: string | Uint8Array): void | Promise; + makeTable?( + tableId: string, + data: string | Uint8Array, + ): void | Promise; +} + +export function createMessageHandler( + mod: typeof perspective, + handler: VirtualServerHandler, +) { + let virtualServer: perspective.JsVirtualServer; + async function postMessage(port: MessagePort, msg: MessageEvent) { + if (msg.data.cmd === "init") { + try { + virtualServer = new mod.JsVirtualServer(handler); + if (msg.data.id !== undefined) { + port.postMessage({ id: msg.data.id }); + } else { + port.postMessage(null); + } + } catch (error) { + console.error("Error initializing worker:", error); + throw error; + } + } else { + try { + const requestBytes = new Uint8Array(msg.data); + const responseBytes = + await virtualServer.handleRequest(requestBytes); + const buffer = responseBytes.slice().buffer; + port.postMessage(buffer, { transfer: [buffer] }); + } catch (error) { + console.error("Error handling request in worker:", error); + throw error; + } + } + } + + const channel = new MessageChannel(); + channel.port1.onmessage = (message) => { + postMessage(channel.port1, message); + }; + + return channel.port2; } /** diff --git a/examples/nodejs-virtual-server/src/worker.js b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts similarity index 65% rename from examples/nodejs-virtual-server/src/worker.js rename to rust/perspective-js/src/ts/virtual_servers/duckdb.ts index d6babde947..cd4c204e89 100644 --- a/examples/nodejs-virtual-server/src/worker.js +++ b/rust/perspective-js/src/ts/virtual_servers/duckdb.ts @@ -10,12 +10,14 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import perspective_client from "@perspective-dev/client"; -import { VirtualServer } from "@perspective-dev/client"; -import * as duckdb from "@duckdb/duckdb-wasm"; - -import CLIENT_WASM from "@perspective-dev/client/dist/wasm/perspective-js.wasm"; -import SUPERSTORE_ARROW from "superstore-arrow/superstore.lz4.arrow"; +import type { + VirtualDataSlice, + VirtualServerHandler, +} from "@perspective-dev/client"; +import type { ColumnType } from "@perspective-dev/client/dist/esm/ts-rs/ColumnType.d.ts"; +import type { ViewConfig } from "@perspective-dev/client/dist/esm/ts-rs/ViewConfig.d.ts"; +import type { ViewWindow } from "@perspective-dev/client/dist/esm/ts-rs/ViewWindow.d.ts"; +import type * as duckdb from "@duckdb/duckdb-wasm"; const NUMBER_AGGS = [ "sum", @@ -65,14 +67,7 @@ const FILTER_OPS = [ "<", ]; -let db; - -const tableMetadata = { - tables: [], - schemas: new Map(), -}; - -function duckdbTypeToPsp(name) { +function duckdbTypeToPsp(name: string): ColumnType { if (name === "VARCHAR") return "string"; if (name === "DOUBLE" || name === "BIGINT" || name === "HUGEINT") return "float"; @@ -88,7 +83,7 @@ function duckdbTypeToPsp(name) { throw new Error(`Unknown type '${name}'`); } -function convertDecimalToNumber(value, dtypeString) { +function convertDecimalToNumber(value: any, dtypeString: string) { if ( value === null || value === undefined || @@ -117,13 +112,31 @@ function convertDecimalToNumber(value, dtypeString) { } } -async function runQuery(query, options = {}) { +async function runQuery( + db: duckdb.AsyncDuckDBConnection, + query: string, + options: { columns: true }, +): Promise<{ + rows: any[]; + columns: string[]; + dtypes: string[]; +}>; + +async function runQuery( + db: duckdb.AsyncDuckDBConnection, + query: string, + options?: { columns: boolean }, +): Promise; + +async function runQuery( + db: duckdb.AsyncDuckDBConnection, + query: string, + options: { columns?: boolean } = {}, +) { query = query.replace(/\s+/g, " ").trim(); - console.log("Query:", query); - /** @type {duckdb.AsyncDuckDBConnection} */ - const c = await db.connect(); + // console.log("Query:", query); try { - const result = await c.query(query); + const result = await db.query(query); if (options.columns) { return { rows: result.toArray(), @@ -131,17 +144,22 @@ async function runQuery(query, options = {}) { dtypes: result.schema.fields.map((f) => f.type.toString()), }; } + return result.toArray(); } catch (error) { console.error("Query error:", error); console.error("Query:", query); throw error; - } finally { - await c.close(); } } -const handler = { +export class DuckDBHandler implements VirtualServerHandler { + private db: duckdb.AsyncDuckDBConnection; + + constructor(db: duckdb.AsyncDuckDBConnection) { + this.db = db; + } + getFeatures() { return { group_by: true, @@ -165,56 +183,55 @@ const handler = { datetime: STRING_AGGS, }, }; - }, + } - getHostedTables() { - return tableMetadata.tables; - }, + async getHostedTables() { + const results = await runQuery(this.db, "SHOW ALL TABLES"); + return results.map((row) => row.toJSON().name); + } - async tableSchema(tableId) { + async tableSchema(tableId: string) { const query = `DESCRIBE ${tableId}`; - const results = await runQuery(query); - const schema = {}; + const results = await runQuery(this.db, query); + const schema = {} as Record; for (const result of results) { const res = result.toJSON(); const colName = res.column_name; if (!colName.startsWith("__") || !colName.endsWith("__")) { - const cleanName = colName.split("_").slice(-1)[0]; + const cleanName = colName.split("_").slice(-1)[0] as string; schema[cleanName] = duckdbTypeToPsp(res.column_type); } } + return schema; - }, + } - async tableColumnsSize(tableId, config) { - const query = `SELECT COUNT(*) FROM (DESCRIBE ${tableId})`; - const results = await runQuery(query); + async viewColumnSize(viewId: string, config: ViewConfig) { + const query = `SELECT COUNT(*) FROM (DESCRIBE ${viewId})`; + const results = await runQuery(this.db, query); const gs = config.group_by?.length || 0; const count = Number(Object.values(results[0].toJSON())[0]); return ( count - (gs === 0 ? 0 : gs + (config.split_by?.length === 0 ? 1 : 0)) ); - }, + } - async tableSize(tableId) { + async tableSize(tableId: string) { const query = `SELECT COUNT(*) FROM ${tableId}`; - const results = await runQuery(query); + const results = await runQuery(this.db, query); return Number(results[0].toJSON()["count_star()"]); - }, - - async viewSchema(viewId, config) { - return this.tableSchema(viewId); - }, + } - async viewSize(viewId) { - return this.tableSize(viewId); - }, + // async viewSchema(viewId: string, config: ViewConfig) { + // return this.tableSchema(viewId); + // } - async tableMakeView(tableId, viewId, config) { - console.log(`tableMakeView: ${tableId} -> ${viewId}`, config); - console.log(`aggregates:`, JSON.stringify(config.aggregates, null, 2)); + // async viewSize(viewId: string) { + // return this.tableSize(viewId); + // } + async tableMakeView(tableId: string, viewId: string, config: ViewConfig) { const columns = config.columns || []; const group_by = config.group_by || []; const split_by = config.split_by || []; @@ -223,19 +240,22 @@ const handler = { const expressions = config.expressions || {}; const filter = config.filter || []; - const colName = (col) => { + const colName = (col: string) => { const expr = expressions[col]; return expr || `"${col}"`; }; - const getAggregate = (col) => aggregates[col] || null; + const getAggregate = (col: string) => aggregates[col] || null; const generateSelectClauses = () => { const clauses = []; if (group_by.length > 0) { for (const col of columns) { - const agg = getAggregate(col) || "any_value"; - clauses.push(`${agg}(${colName(col)}) as "${col}"`); + if (col !== null) { + // TODO texodus + const agg = getAggregate(col) || "any_value"; + clauses.push(`${agg}(${colName(col)}) as "${col}"`); + } } if (split_by.length === 0) { @@ -250,11 +270,15 @@ const handler = { } } else if (columns.length > 0) { for (const col of columns) { - clauses.push( - `${colName(col)} as "${col.replace(/"/g, '""')}"`, - ); + if (col !== null) { + // TODO texodus + clauses.push( + `${colName(col)} as "${col.replace(/"/g, '""')}"`, + ); + } } } + return clauses; }; @@ -364,24 +388,26 @@ const handler = { } query = `CREATE TABLE ${viewId} AS (${query})`; - await runQuery(query); - }, + await runQuery(this.db, query); + } - async tableValidateExpression(tableId, expression) { + async tableValidateExpression(tableId: string, expression: string) { const query = `DESCRIBE (select ${expression} from ${tableId})`; - const results = await runQuery(query); - return duckdbTypeToPsp(results[0][1]); - }, + const results = await runQuery(this.db, query); + return duckdbTypeToPsp(results[0].toJSON()["column_type"]); + } - async viewDelete(viewId) { - console.log(`Deleting view ${viewId}`); + async viewDelete(viewId: string) { const query = `DROP TABLE IF EXISTS ${viewId}`; - await runQuery(query); - }, - - async viewGetData(viewId, config, viewport, dataSlice) { - console.log(`viewGetData: ${viewId}`, viewport); + await runQuery(this.db, query); + } + async viewGetData( + viewId: string, + config: ViewConfig, + viewport: ViewWindow, + dataSlice: VirtualDataSlice, + ) { const group_by = config.group_by || []; const split_by = config.split_by || []; const start_col = viewport.start_col; @@ -395,7 +421,7 @@ const handler = { } const schemaQuery = `DESCRIBE ${viewId}`; - const schemaResults = await runQuery(schemaQuery); + const schemaResults = await runQuery(this.db, schemaQuery); const columnTypes = new Map(); for (const result of schemaResults) { const res = result.toJSON(); @@ -426,17 +452,10 @@ const handler = { FROM ${viewId} ${limit} `; - const { rows, columns, dtypes } = await runQuery(query, { + const { rows, columns, dtypes } = await runQuery(this.db, query, { columns: true, }); - console.log("viewGetData columns:", columns); - console.log("viewGetData dtypes:", dtypes); - console.log( - "viewGetData first 3 rows:", - rows.slice(0, 3).map((r) => r.toArray()), - ); - for (let cidx = 0; cidx < columns.length; cidx++) { const col = columns[cidx]; @@ -488,151 +507,5 @@ const handler = { } } } - }, -}; - -async function initializeDuckDB() { - const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); - - const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); - - const worker_url = URL.createObjectURL( - new Blob([`importScripts("${bundle.mainWorker}");`], { - type: "text/javascript", - }), - ); - - const worker = new Worker(worker_url); - const logger = new duckdb.ConsoleLogger(); - db = new duckdb.AsyncDuckDB(logger, worker); - await db.instantiate(bundle.mainModule, bundle.pthreadWorker); - URL.revokeObjectURL(worker_url); - - const c = await db.connect(); - await c.query(` - SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; - `); - await c.close(); - - globalThis.db = db; - console.log("DuckDB initialized"); -} - -async function loadSampleData() { - const c = await db.connect(); - - try { - const response = await fetch(SUPERSTORE_ARROW); - const arrayBuffer = await response.arrayBuffer(); - - await c.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { - name: "data_source_one", - create: true, - }); - - const checkResult = await c.query( - "SELECT COUNT(*) as cnt FROM data_source_one", - ); - const rowCount = checkResult.toArray()[0].cnt; - console.log(`Superstore data loaded from Arrow IPC (${rowCount} rows)`); - } catch (error) { - console.error("Error loading Arrow data:", error); - await c.query(` - CREATE TABLE data_source_one AS - SELECT * FROM ( - VALUES - ('Furniture', 'Office Supplies', 'East', 100.0, 10), - ('Technology', 'Electronics', 'West', 200.0, 20), - ('Furniture', 'Chairs', 'East', 150.0, 15), - ('Technology', 'Computers', 'South', 300.0, 30) - ) AS t(Category, "Sub-Category", Region, Sales, Quantity) - `); - console.log("Loaded fallback sample data"); - } finally { - await c.close(); - } - - await cacheTableMetadata(); -} - -async function cacheTableMetadata() { - try { - console.log("Caching table metadata..."); - - const results = await runQuery("SHOW ALL TABLES"); - console.log("SHOW ALL TABLES results:", results); - - tableMetadata.tables = results.map((row) => row.toJSON().name); - - console.log("Extracted table names:", tableMetadata.tables); - - for (const tableName of tableMetadata.tables) { - const query = `DESCRIBE ${tableName}`; - const schemaResults = await runQuery(query); - const schema = {}; - for (const result of schemaResults) { - const res = result.toJSON(); - const colName = res.column_name; - if (!colName.startsWith("__") || !colName.endsWith("__")) { - const cleanName = colName.split("_").slice(-1)[0]; - schema[cleanName] = duckdbTypeToPsp(res.column_type); - } - } - tableMetadata.schemas.set(tableName, schema); - } - - console.log("Cached metadata for tables:", tableMetadata.tables); - console.log("Full metadata:", tableMetadata); - } catch (error) { - console.error("Error caching table metadata:", error); - throw error; - } -} - -let virtualServer; -let port; - -function bindPort(e) { - port = e.ports ? e.ports[0] : self; - - port.addEventListener("message", async (msg) => { - if (msg.data.cmd === "init") { - try { - await initializeDuckDB(); - await perspective_client.init_client(fetch(CLIENT_WASM)); - await loadSampleData(); - - virtualServer = new VirtualServer(handler); - - console.log("VirtualServer initialized"); - - if (msg.data.id !== undefined) { - port.postMessage({ id: msg.data.id }); - } else { - port.postMessage(null); - } - } catch (error) { - console.error("Error initializing worker:", error); - throw error; - } - } else { - try { - const requestBytes = new Uint8Array(msg.data); - const responseBytes = - await virtualServer.handleRequest(requestBytes); - const buffer = responseBytes.slice().buffer; - port.postMessage(buffer, { transfer: [buffer] }); - } catch (error) { - console.error("Error handling request in worker:", error); - throw error; - } - } - }); - - if (port !== self) { - port.start(); } } - -self.addEventListener("connect", bindPort); -self.addEventListener("message", bindPort); diff --git a/rust/perspective-js/src/ts/wasm/browser.ts b/rust/perspective-js/src/ts/wasm/browser.ts index f30b12120d..b2d2295444 100644 --- a/rust/perspective-js/src/ts/wasm/browser.ts +++ b/rust/perspective-js/src/ts/wasm/browser.ts @@ -32,7 +32,10 @@ function invert_promise(): [ ]; } -async function _init(ws: MessagePort | Worker, wasm: WebAssembly.Module) { +async function _init( + ws: MessagePort | Worker, + wasm: WebAssembly.Module | undefined, +) { const [sender, receiver] = invert_promise(); ws.addEventListener("message", function listener(resp) { ws.removeEventListener("message", listener); @@ -47,7 +50,12 @@ async function _init(ws: MessagePort | Worker, wasm: WebAssembly.Module) { ws.onmessageerror = console.error; ws.postMessage( { cmd: "init", args: [wasm] }, - { transfer: wasm instanceof WebAssembly.Module ? [] : [wasm] }, + { + transfer: + wasm === undefined || wasm instanceof WebAssembly.Module + ? [] + : [wasm], + }, ); await receiver; @@ -61,11 +69,13 @@ async function _init(ws: MessagePort | Worker, wasm: WebAssembly.Module) { */ export async function worker( module: Promise, - server_wasm: Promise, - perspective_wasm_worker: Promise, + server_wasm: Promise | undefined, + perspective_wasm_worker: Promise< + SharedWorker | ServiceWorker | Worker | MessagePort + >, ) { let [wasm, webworker]: [ - WebAssembly.Module, + WebAssembly.Module | undefined, SharedWorker | ServiceWorker | Worker | MessagePort, ] = await Promise.all([server_wasm, perspective_wasm_worker]); @@ -77,10 +87,8 @@ export async function worker( ) { port = webworker.port; } else { - webworker = webworker as ServiceWorker | Worker | MessagePort; - const messageChannel = new MessageChannel(); - webworker.postMessage(null, [messageChannel.port2]); - port = messageChannel.port1; + // Assume `MessagePort` + port = webworker as MessagePort; } const client = new Client( diff --git a/rust/perspective-js/test/js/duckdb.spec.js b/rust/perspective-js/test/js/duckdb.spec.js new file mode 100644 index 0000000000..c447a3cb6f --- /dev/null +++ b/rust/perspective-js/test/js/duckdb.spec.js @@ -0,0 +1,735 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import * as fs from "fs"; +import * as path from "path"; +import { createRequire } from "module"; + +import * as duckdb from "@duckdb/duckdb-wasm"; + +import { test, expect } from "@perspective-dev/test"; +import { + default as perspective, + createMessageHandler, +} from "@perspective-dev/client"; +import { DuckDBHandler } from "@perspective-dev/client/dist/esm/virtual_servers/duckdb.js"; + +const require = createRequire(import.meta.url); +const DUCKDB_DIST = path.dirname(require.resolve("@duckdb/duckdb-wasm")); +const Worker = require("web-worker"); + +async function initializeDuckDB() { + const bundle = await duckdb.selectBundle({ + mvp: { + mainModule: path.resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"), + mainWorker: path.resolve( + DUCKDB_DIST, + "./duckdb-node-mvp.worker.cjs", + ), + }, + eh: { + mainModule: path.resolve(DUCKDB_DIST, "./duckdb-eh.wasm"), + mainWorker: path.resolve( + DUCKDB_DIST, + "./duckdb-node-eh.worker.cjs", + ), + }, + }); + + const logger = new duckdb.ConsoleLogger(); + const worker = new Worker(bundle.mainWorker); + const db = new duckdb.AsyncDuckDB(logger, worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + const c = await db.connect(); + await c.query(` + SET default_null_order=NULLS_FIRST_ON_ASC_LAST_ON_DESC; + `); + + return c; +} + +async function loadSuperstoreData(db) { + const arrowPath = path.resolve( + import.meta.dirname, + "../../node_modules/superstore-arrow/superstore.lz4.arrow", + ); + + const arrayBuffer = fs.readFileSync(arrowPath); + await db.insertArrowFromIPCStream(new Uint8Array(arrayBuffer), { + name: "superstore", + create: true, + }); +} + +test.describe("DuckDB Virtual Server", function () { + let db; + let client; + + test.beforeAll(async () => { + db = await initializeDuckDB(); + const server = createMessageHandler(new DuckDBHandler(db)); + client = await perspective.worker(server); + await loadSuperstoreData(db); + }); + + test.describe("client", () => { + test("get_hosted_table_names()", async function () { + const tables = await client.get_hosted_table_names(); + expect(tables).toContain("superstore"); + }); + }); + + test.describe("table", () => { + test("schema()", async function () { + const table = await client.open_table("superstore"); + const schema = await table.schema(); + expect(schema).toHaveProperty("Sales"); + expect(schema).toHaveProperty("Profit"); + expect(schema).toHaveProperty("State"); + expect(schema).toHaveProperty("Quantity"); + expect(schema).toHaveProperty("Discount"); + }); + + test("schema() returns correct types", async function () { + const table = await client.open_table("superstore"); + const schema = await table.schema(); + expect(schema["Sales"]).toBe("float"); + expect(schema["Profit"]).toBe("float"); + expect(schema["Quantity"]).toBe("integer"); + expect(schema["State"]).toBe("string"); + expect(schema["Order Date"]).toBe("date"); + }); + + test("columns()", async function () { + const table = await client.open_table("superstore"); + const columns = await table.columns(); + expect(columns).toContain("Sales"); + expect(columns).toContain("Profit"); + expect(columns).toContain("State"); + expect(columns).toContain("Region"); + expect(columns).toContain("Category"); + }); + + test("size()", async function () { + const table = await client.open_table("superstore"); + const size = await table.size(); + expect(size).toBe(9994); + }); + }); + + test.describe("view", () => { + test("num_rows()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ columns: ["Sales", "Profit"] }); + const numRows = await view.num_rows(); + expect(numRows).toBe(9994); + await view.delete(); + }); + + test("num_columns()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + + const numColumns = await view.num_columns(); + expect(numColumns).toBe(3); + await view.delete(); + }); + + test("schema()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + const schema = await view.schema(); + expect(schema).toEqual({ + Sales: "float", + Profit: "float", + State: "string", + }); + await view.delete(); + }); + + test("to_json()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 3 }); + expect(json.length).toBe(3); + expect(json[0]).toHaveProperty("Sales"); + expect(json[0]).toHaveProperty("Quantity"); + await view.delete(); + }); + + test("to_columns()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + }); + const columns = await view.to_columns({ + start_row: 0, + end_row: 3, + }); + expect(columns).toHaveProperty("Sales"); + expect(columns).toHaveProperty("Quantity"); + expect(columns["Sales"].length).toBe(3); + expect(columns["Quantity"].length).toBe(3); + await view.delete(); + }); + + test("column_paths()", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "State"], + }); + const paths = await view.column_paths(); + expect(paths).toEqual(["Sales", "Profit", "State"]); + await view.delete(); + }); + }); + + test.describe("group_by", () => { + test("single group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "sum" }, + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(5); // 4 regions + 1 total row + const json = await view.to_json(); + expect(json[0]).toHaveProperty("__ROW_PATH__"); + expect(json[0]["__ROW_PATH__"]).toEqual([]); + await view.delete(); + }); + + test("multi-level group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region", "Category"], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + // First row should be total + expect(json[0]["__ROW_PATH__"]).toEqual([]); + // Should have region-level rows and region+category rows + const regionRows = json.filter( + (row) => row["__ROW_PATH__"].length === 1, + ); + expect(regionRows.length).toBe(4); // 4 regions + await view.delete(); + }); + + test("group_by with count aggregate", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + aggregates: { Sales: "count" }, + }); + const json = await view.to_json(); + // Total count should be 9994 + expect(json[0]["Sales"]).toBe(9994); + await view.delete(); + }); + + test("group_by with avg aggregate", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + aggregates: { Sales: "avg" }, + }); + const json = await view.to_json(); + expect(json.length).toBe(4); // 3 categories + total + // Each row should have an average value + for (const row of json) { + expect(typeof row["Sales"]).toBe("number"); + } + await view.delete(); + }); + + test("group_by with min aggregate", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "min" }, + }); + const json = await view.to_json(); + for (const row of json) { + expect(typeof row["Quantity"]).toBe("number"); + } + await view.delete(); + }); + + test("group_by with max aggregate", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Quantity"], + group_by: ["Region"], + aggregates: { Quantity: "max" }, + }); + const json = await view.to_json(); + for (const row of json) { + expect(typeof row["Quantity"]).toBe("number"); + } + await view.delete(); + }); + }); + + test.describe.skip("split_by", () => { + test("single split_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Region"], + group_by: ["Category"], + aggregates: { Sales: "sum" }, + }); + + const columns = await view.column_paths(); + // Should have columns for each region + expect(columns.some((c) => c.includes("Central"))).toBe(true); + expect(columns.some((c) => c.includes("East"))).toBe(true); + expect(columns.some((c) => c.includes("South"))).toBe(true); + expect(columns.some((c) => c.includes("West"))).toBe(true); + await view.delete(); + }); + + test("split_by without group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + split_by: ["Category"], + }); + const paths = await view.column_paths(); + expect(paths.some((c) => c.includes("Furniture"))).toBe(true); + expect(paths.some((c) => c.includes("Office Supplies"))).toBe(true); + expect(paths.some((c) => c.includes("Technology"))).toBe(true); + await view.delete(); + }); + }); + + test.describe("filter", () => { + test("filter with equals", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "==", "West"]], + }); + const json = await view.to_json(); + for (const row of json) { + expect(row["Region"]).toBe("West"); + } + await view.delete(); + }); + + test("filter with not equals", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Region"], + filter: [["Region", "!=", "West"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Region"]).not.toBe("West"); + } + await view.delete(); + }); + + test("filter with greater than", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">", 5]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Quantity"]).toBeGreaterThan(5); + } + await view.delete(); + }); + + test("filter with less than", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<", 3]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Quantity"]).toBeLessThan(3); + } + await view.delete(); + }); + + test("filter with greater than or equal", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", ">=", 10]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Quantity"]).toBeGreaterThanOrEqual(10); + } + await view.delete(); + }); + + test("filter with less than or equal", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + filter: [["Quantity", "<=", 2]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Quantity"]).toBeLessThanOrEqual(2); + } + await view.delete(); + }); + + test("filter with LIKE", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "State"], + filter: [["State", "LIKE", "Cal%"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["State"].startsWith("Cal")).toBe(true); + } + await view.delete(); + }); + + test("multiple filters", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Region", "Quantity"], + filter: [ + ["Region", "==", "West"], + ["Quantity", ">", 3], + ], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + for (const row of json) { + expect(row["Region"]).toBe("West"); + expect(row["Quantity"]).toBeGreaterThan(3); + } + await view.delete(); + }); + + test("filter with group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + filter: [["Region", "==", "West"]], + aggregates: { Sales: "sum" }, + }); + const numRows = await view.num_rows(); + expect(numRows).toBe(4); // 3 categories + total + await view.delete(); + }); + }); + + test.describe("sort", () => { + test("sort ascending", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "asc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (let i = 1; i < json.length; i++) { + expect(json[i]["Sales"]).toBeGreaterThanOrEqual( + json[i - 1]["Sales"], + ); + } + await view.delete(); + }); + + test("sort descending", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Quantity"], + sort: [["Sales", "desc"]], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (let i = 1; i < json.length; i++) { + expect(json[i]["Sales"]).toBeLessThanOrEqual( + json[i - 1]["Sales"], + ); + } + await view.delete(); + }); + + test("sort with group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Region"], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + // Skip the first row (total) and verify sorting + const regionRows = json.slice(1); + for (let i = 1; i < regionRows.length; i++) { + expect(regionRows[i]["Sales"]).toBeLessThanOrEqual( + regionRows[i - 1]["Sales"], + ); + } + await view.delete(); + }); + + test("multi-column sort", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Region", "Sales", "Quantity"], + sort: [ + ["Region", "asc"], + ["Sales", "desc"], + ], + }); + const json = await view.to_json({ start_row: 0, end_row: 100 }); + // Check that Region is sorted first + let lastRegion = ""; + let lastSales = Infinity; + for (const row of json) { + if (row["Region"] !== lastRegion) { + lastRegion = row["Region"]; + lastSales = Infinity; + } + expect(row["Sales"]).toBeLessThanOrEqual(lastSales); + lastSales = row["Sales"]; + } + await view.delete(); + }); + }); + + test.describe("expressions", () => { + test("simple expression", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "doublesales"], + expressions: { doublesales: '"Sales" * 2' }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + console.log(row); + expect(row["doublesales"]).toBeCloseTo(row["Sales"] * 2, 5); + } + + await view.delete(); + }); + + test("expression with multiple columns", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "margin"], + expressions: { margin: '"Profit" / "Sales"' }, + }); + + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + if (row["Sales"] !== 0) { + expect(row["margin"]).toBeCloseTo( + row["Profit"] / row["Sales"], + 5, + ); + } + } + + await view.delete(); + }); + + test("expression with group_by", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["total"], + group_by: ["Region"], + expressions: { total: '"Sales" + "Profit"' }, + aggregates: { total: "sum" }, + }); + + const json = await view.to_json(); + expect(json.length).toBe(5); // 4 regions + total + for (const row of json) { + expect(typeof row["total"]).toBe("number"); + } + + await view.delete(); + }); + }); + + test.describe("viewport", () => { + test("start_row and end_row", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 10, end_row: 20 }); + expect(json.length).toBe(10); + await view.delete(); + }); + + test("start_col and end_col", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit", "Quantity", "Discount"], + }); + const json = await view.to_json({ + start_row: 0, + end_row: 5, + start_col: 1, + end_col: 3, + }); + expect(json.length).toBe(5); + // Should only have Profit and Quantity (columns 1 and 2) + expect(Object.keys(json[0]).sort()).toEqual( + ["Profit", "Quantity"].sort(), + ); + await view.delete(); + }); + + test("large viewport", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + }); + const json = await view.to_json({ start_row: 0, end_row: 1000 }); + expect(json.length).toBe(1000); + await view.delete(); + }); + }); + + test.describe("data types", () => { + test("integer columns", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Quantity"], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + expect(Number.isInteger(row["Quantity"])).toBe(true); + } + await view.delete(); + }); + + test("float columns", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales", "Profit"], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + expect(typeof row["Sales"]).toBe("number"); + expect(typeof row["Profit"]).toBe("number"); + } + await view.delete(); + }); + + test("string columns", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Region", "State", "City"], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + expect(typeof row["Region"]).toBe("string"); + expect(typeof row["State"]).toBe("string"); + expect(typeof row["City"]).toBe("string"); + } + await view.delete(); + }); + + test("date columns", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Order Date"], + }); + const json = await view.to_json({ start_row: 0, end_row: 10 }); + for (const row of json) { + // Dates come as timestamps + expect(typeof row["Order Date"]).toBe("number"); + } + await view.delete(); + }); + }); + + test.describe("combined operations", () => { + test("group_by + filter + sort", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + filter: [["Region", "==", "West"]], + sort: [["Sales", "desc"]], + aggregates: { Sales: "sum" }, + }); + const json = await view.to_json(); + expect(json.length).toBe(4); // 3 categories + total + // Skip total row and verify sorting + const categoryRows = json.slice(1); + for (let i = 1; i < categoryRows.length; i++) { + expect(categoryRows[i]["Sales"]).toBeLessThanOrEqual( + categoryRows[i - 1]["Sales"], + ); + } + await view.delete(); + }); + + test.skip("split_by + group_by + filter", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["Sales"], + group_by: ["Category"], + split_by: ["Region"], + filter: [["Quantity", ">", 3]], + aggregates: { Sales: "sum" }, + }); + const paths = await view.column_paths(); + expect(paths.length).toBeGreaterThan(0); + const numRows = await view.num_rows(); + expect(numRows).toBe(4); // 3 categories + total + await view.delete(); + }); + + test("expressions + group_by + sort", async function () { + const table = await client.open_table("superstore"); + const view = await table.view({ + columns: ["profitmargin"], + group_by: ["Region"], + expressions: { profitmargin: '"Profit" / "Sales" * 100' }, + sort: [["profitmargin", "desc"]], + aggregates: { profitmargin: "avg" }, + }); + const json = await view.to_json(); + expect(json.length).toBe(5); // 4 regions + total + // Verify sorting on region rows + const regionRows = json.slice(1); + for (let i = 1; i < regionRows.length; i++) { + expect(regionRows[i]["profitmargin"]).toBeLessThanOrEqual( + regionRows[i - 1]["profitmargin"], + ); + } + await view.delete(); + }); + }); +}); diff --git a/rust/perspective-js/tsconfig.browser.json b/rust/perspective-js/tsconfig.browser.json index 76106be6c0..cd49f75867 100644 --- a/rust/perspective-js/tsconfig.browser.json +++ b/rust/perspective-js/tsconfig.browser.json @@ -9,7 +9,8 @@ "rootDir": "./src/ts", "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "skipLibCheck": true, "types": [] }, - "files": ["./src/ts/perspective.browser.ts"] + "files": ["./src/ts/perspective.browser.ts", "./src/ts/virtual_servers/duckdb.ts"] } diff --git a/rust/perspective-js/tsconfig.json b/rust/perspective-js/tsconfig.json index ad76cb90e5..186a6d6c4b 100644 --- a/rust/perspective-js/tsconfig.json +++ b/rust/perspective-js/tsconfig.json @@ -15,6 +15,7 @@ "include": [ "./src/ts/perspective.node.ts", "./src/ts/perspective.cdn.ts", + "./src/ts/virtual_servers/duckdb.ts", "./test/js/*.ts" ] } diff --git a/rust/perspective-python/Cargo.toml b/rust/perspective-python/Cargo.toml index 0600eff5d2..8e9488ac6e 100644 --- a/rust/perspective-python/Cargo.toml +++ b/rust/perspective-python/Cargo.toml @@ -59,7 +59,6 @@ python-config-rs = "0.1.2" [dependencies] perspective-client = { version = "4.0.1" } perspective-server = { version = "4.0.1" } -perspective-server-virtual = { version = "4.0.1" } bytes = "1.10.1" chrono = "0.4" macro_rules_attribute = "0.2.0" diff --git a/rust/perspective-python/src/server/virtual_server_sync.rs b/rust/perspective-python/src/server/virtual_server_sync.rs index 3f02b26c7d..86cbde1693 100644 --- a/rust/perspective-python/src/server/virtual_server_sync.rs +++ b/rust/perspective-python/src/server/virtual_server_sync.rs @@ -16,8 +16,8 @@ use std::sync::{Arc, Mutex}; use chrono::{DateTime, TimeZone, Utc}; use indexmap::IndexMap; use perspective_client::proto::{ColumnType, HostedTable}; -use perspective_server_virtual::{ - Features, MaybeSendFuture, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerHandler, +use perspective_client::virtual_server::{ + Features, ResultExt, VirtualDataSlice, VirtualServer, VirtualServerFuture, VirtualServerHandler, }; use pyo3::exceptions::PyValueError; use pyo3::types::{ @@ -31,7 +31,7 @@ pub struct PyServerHandler(Py); impl VirtualServerHandler for PyServerHandler { type Error = PyErr; - fn get_features(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { + fn get_features(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); Box::pin(async move { Python::with_gil(|py| { @@ -49,7 +49,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn get_hosted_tables(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { + fn get_hosted_tables(&self) -> VirtualServerFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); Box::pin(async move { Python::with_gil(|py| { @@ -80,7 +80,7 @@ impl VirtualServerHandler for PyServerHandler { fn table_schema( &self, table_id: &str, - ) -> MaybeSendFuture<'_, Result, Self::Error>> { + ) -> VirtualServerFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); Box::pin(async move { @@ -97,7 +97,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn table_size(&self, table_id: &str) -> MaybeSendFuture<'_, Result> { + fn table_size(&self, table_id: &str) -> VirtualServerFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); Box::pin(async move { @@ -109,11 +109,33 @@ impl VirtualServerHandler for PyServerHandler { }) } + fn table_column_size( + &self, + table_id: &str, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let table_id = table_id.to_string(); + + Box::pin(async move { + let has_table_column_size = + Python::with_gil(|py| handler.getattr(py, "table_column_size").is_ok()); + if has_table_column_size { + Python::with_gil(|py| { + handler + .call_method1(py, pyo3::intern!(py, "table_column_size"), (&table_id,))? + .extract::(py) + }) + } else { + Ok(self.table_schema(&table_id).await?.len() as u32) + } + }) + } + fn table_validate_expression( &self, table_id: &str, expression: &str, - ) -> MaybeSendFuture<'_, Result> { + ) -> VirtualServerFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); let expression = expression.to_string(); @@ -139,7 +161,7 @@ impl VirtualServerHandler for PyServerHandler { table_id: &str, view_id: &str, config: &mut perspective_client::config::ViewConfigUpdate, - ) -> MaybeSendFuture<'_, Result> { + ) -> VirtualServerFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let table_id = table_id.to_string(); let view_id = view_id.to_string(); @@ -159,32 +181,11 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn table_columns_size( - &self, - view_id: &str, - config: &perspective_client::config::ViewConfig, - ) -> MaybeSendFuture<'_, Result> { - let handler = Python::with_gil(|py| self.0.clone_ref(py)); - let view_id = view_id.to_string(); - let config = config.clone(); - Box::pin(async move { - Python::with_gil(|py| { - handler - .call_method1( - py, - pyo3::intern!(py, "table_columns_size"), - (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)?, - )? - .extract::(py) - }) - }) - } - fn view_schema( &self, view_id: &str, config: &perspective_client::config::ViewConfig, - ) -> MaybeSendFuture<'_, Result, Self::Error>> { + ) -> VirtualServerFuture<'_, Result, Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); let config = config.clone(); @@ -198,7 +199,15 @@ impl VirtualServerHandler for PyServerHandler { }; Ok(handler - .call_method1(py, pyo3::intern!(py, "view_schema"), args)? + .call_method1( + py, + if has_view_schema { + pyo3::intern!(py, "view_schema") + } else { + pyo3::intern!(py, "table_schema") + }, + args, + )? .downcast_bound::(py)? .items() .extract::>()? @@ -209,7 +218,7 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn view_size(&self, view_id: &str) -> MaybeSendFuture<'_, Result> { + fn view_size(&self, view_id: &str) -> VirtualServerFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); Box::pin(async move { @@ -221,7 +230,34 @@ impl VirtualServerHandler for PyServerHandler { }) } - fn view_delete(&self, view_id: &str) -> MaybeSendFuture<'_, Result<(), Self::Error>> { + fn view_column_size( + &self, + view_id: &str, + config: &perspective_client::config::ViewConfig, + ) -> VirtualServerFuture<'_, Result> { + let handler = Python::with_gil(|py| self.0.clone_ref(py)); + let view_id = view_id.to_string(); + let config = config.clone(); + Box::pin(async move { + let has_table_column_size = + Python::with_gil(|py| handler.getattr(py, "view_column_size").is_ok()); + if has_table_column_size { + Python::with_gil(|py| { + handler + .call_method1( + py, + pyo3::intern!(py, "view_column_size"), + (&view_id, pythonize::pythonize(py, &config)?).into_pyobject(py)?, + )? + .extract::(py) + }) + } else { + Ok(self.view_schema(&view_id, &config).await?.len() as u32) + } + }) + } + + fn view_delete(&self, view_id: &str) -> VirtualServerFuture<'_, Result<(), Self::Error>> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); Box::pin(async move { @@ -237,7 +273,7 @@ impl VirtualServerHandler for PyServerHandler { view_id: &str, config: &perspective_client::config::ViewConfig, viewport: &perspective_client::proto::ViewPort, - ) -> MaybeSendFuture<'_, Result> { + ) -> VirtualServerFuture<'_, Result> { let handler = Python::with_gil(|py| self.0.clone_ref(py)); let view_id = view_id.to_string(); let config = config.clone(); diff --git a/rust/perspective-server-virtual/Cargo.toml b/rust/perspective-server-virtual/Cargo.toml deleted file mode 100644 index 15729f0a88..0000000000 --- a/rust/perspective-server-virtual/Cargo.toml +++ /dev/null @@ -1,44 +0,0 @@ -# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -# ┃ Copyright (c) 2017, the Perspective Authors. ┃ -# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -# ┃ This file is part of the Perspective library, distributed under the terms ┃ -# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -[package] -name = "perspective-server-virtual" -version = "4.0.1" -authors = ["Andrew Stein "] -edition = "2024" -description = "Virtual server implementation for Perspective that delegates to user-provided handlers" -repository = "https://github.com/perspective-dev/perspective" -license = "Apache-2.0" -homepage = "https://perspective-dev.github.io" -keywords = [] - -[lib] -crate-type = ["rlib"] -path = "src/lib.rs" - -[dependencies] -perspective-client = { version = "3.8.0" } -indexmap = { version = "2.2.6", features = ["serde"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0.107" } -thiserror = { version = "1.0.55" } -tracing = { version = ">=0.1.36", optional = true } -futures = "0.3.28" - -[dependencies.prost] -version = "0.12.3" -default-features = false -features = ["prost-derive", "std"] - -[features] -default = [] -logging = ["tracing"] diff --git a/rust/perspective-server-virtual/src/lib.rs b/rust/perspective-server-virtual/src/lib.rs deleted file mode 100644 index 8e98966ee0..0000000000 --- a/rust/perspective-server-virtual/src/lib.rs +++ /dev/null @@ -1,586 +0,0 @@ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ -// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ -// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ -// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ -// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ -// ┃ Copyright (c) 2017, the Perspective Authors. ┃ -// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ -// ┃ This file is part of the Perspective library, distributed under the terms ┃ -// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -use std::borrow::Cow; -use std::collections::HashMap; -use std::error::Error; -use std::future::Future; -use std::ops::{Deref, DerefMut}; -use std::pin::Pin; - -use indexmap::IndexMap; -use perspective_client::config::{Scalar, ViewConfig, ViewConfigUpdate}; -use perspective_client::proto::get_features_resp::{ - AggregateArgs, AggregateOptions, ColumnTypeOptions, -}; -use perspective_client::proto::response::ClientResp; -use perspective_client::proto::table_validate_expr_resp::ExprValidationError; -use perspective_client::proto::{ - ColumnType, GetFeaturesResp, GetHostedTablesResp, HostedTable, MakeTableResp, Request, Response, - TableMakePortReq, TableMakePortResp, TableMakeViewResp, TableOnDeleteResp, - TableRemoveDeleteResp, TableSchemaResp, TableSizeResp, TableValidateExprResp, - ViewColumnPathsResp, ViewDeleteResp, ViewDimensionsResp, ViewExpressionSchemaResp, - ViewGetConfigResp, ViewOnDeleteResp, ViewOnUpdateResp, ViewPort, - ViewRemoveDeleteResp, ViewRemoveOnUpdateResp, ViewSchemaResp, ViewToColumnsStringResp, -}; -use prost::bytes::{Bytes, BytesMut}; -use prost::{DecodeError, EncodeError, Message as ProstMessage}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Clone, Error, Debug)] -pub enum VirtualServerError { - #[error("External Error: {0:?}")] - InternalError(#[from] T), - - #[error("{0}")] - DecodeError(DecodeError), - - #[error("{0}")] - EncodeError(EncodeError), - - #[error("Unknown view '{0}'")] - UnknownViewId(String), - - #[error("Invalid JSON'{0}'")] - InvalidJSON(std::sync::Arc), - - #[error("{0}")] - Other(String), -} - -pub trait ResultExt { - fn get_internal_error(self) -> Result>; -} - -impl ResultExt for Result> { - fn get_internal_error(self) -> Result> { - match self { - Ok(x) => Ok(x), - Err(VirtualServerError::InternalError(x)) => Err(Ok(x)), - Err(x) => Err(Err(x.to_string())), - } - } -} - -macro_rules! respond { - ($msg:ident, $name:ident { $($rest:tt)* }) => {{ - let mut resp = BytesMut::new(); - let resp2 = ClientResp::$name($name { - $($rest)* - }); - - Response { - msg_id: $msg.msg_id, - entity_id: $msg.entity_id, - client_resp: Some(resp2), - }.encode(&mut resp).map_err(VirtualServerError::EncodeError)?; - - resp.freeze() - }}; -} - -// Type alias for futures that conditionally includes Send bound -// WASM is single-threaded, so futures don't need to be Send -// Non-WASM targets support multi-threading, so futures must be Send -#[cfg(target_arch = "wasm32")] -pub type MaybeSendFuture<'a, T> = Pin + 'a>>; - -#[cfg(not(target_arch = "wasm32"))] -pub type MaybeSendFuture<'a, T> = Pin + Send + 'a>>; - -pub trait VirtualServerHandler { - type Error: std::error::Error + Send + Sync + 'static; - - // Required methods - fn get_hosted_tables(&self) -> MaybeSendFuture<'_, Result, Self::Error>>; - fn table_schema(&self, table_id: &str) -> MaybeSendFuture<'_, Result, Self::Error>>; - fn table_size(&self, table_id: &str) -> MaybeSendFuture<'_, Result>; - fn table_columns_size(&self, table_id: &str, config: &ViewConfig) -> MaybeSendFuture<'_, Result>; - fn table_make_view( - &mut self, - entity_id: &str, - view_id: &str, - config: &mut ViewConfigUpdate, - ) -> MaybeSendFuture<'_, Result>; - - fn view_size(&self, view_id: &str) -> MaybeSendFuture<'_, Result>; - fn view_delete(&self, view_id: &str) -> MaybeSendFuture<'_, Result<(), Self::Error>>; - fn view_schema( - &self, - entity_id: &str, - config: &ViewConfig, - ) -> MaybeSendFuture<'_, Result, Self::Error>>; - - fn view_get_data( - &self, - view_id: &str, - config: &ViewConfig, - viewport: &ViewPort, - ) -> MaybeSendFuture<'_, Result>; - - // Optional methods - fn table_validate_expression( - &self, - _table_id: &str, - _expression: &str, - ) -> MaybeSendFuture<'_, Result> { - Box::pin(async { Ok(ColumnType::Float) }) - } - - fn get_features(&self) -> MaybeSendFuture<'_, Result, Self::Error>> { - Box::pin(async { Ok(Features::default()) }) - } - - fn table_make_port(&self, _req: &TableMakePortReq) -> MaybeSendFuture<'_, Result> { - Box::pin(async { Ok(0) }) - } - - fn make_table(&mut self, _table_id: &str, _data: &perspective_client::proto::MakeTableData) -> MaybeSendFuture<'_, Result<(), Self::Error>> { - Box::pin(async { unimplemented!("make_table not implemented") }) - } -} - -// output format -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub enum VirtualDataColumn { - Boolean(Vec>), - String(Vec>), - Float(Vec>), - Integer(Vec>), - Datetime(Vec>), - IntegerIndex(Vec>>), - RowPath(Vec>), -} - -pub trait SetVirtualDataColumn { - fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str>; - fn new_column() -> VirtualDataColumn; - fn to_scalar(self) -> Scalar; -} - -macro_rules! template_psp { - ($t:ty, $u:ident, $v:ident, $w:ty) => { - impl SetVirtualDataColumn for Option<$t> { - fn write_to(self, col: &mut VirtualDataColumn) -> Result<(), &'static str> { - if let VirtualDataColumn::$u(x) = col { - x.push(self); - Ok(()) - } else { - Err("Bad type") - } - } - - fn new_column() -> VirtualDataColumn { - VirtualDataColumn::$u(vec![]) - } - - fn to_scalar(self) -> Scalar { - if let Some(x) = self { - Scalar::$v(x as $w) - } else { - Scalar::Null - } - } - } - }; -} - -template_psp!(String, String, String, String); -template_psp!(f64, Float, Float, f64); -template_psp!(i32, Integer, Float, f64); -template_psp!(i64, Datetime, Float, f64); -template_psp!(bool, Boolean, Bool, bool); - -#[derive(Debug, Default, Serialize)] -pub struct VirtualDataSlice(IndexMap); - -impl Deref for VirtualDataSlice { - type Target = IndexMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for VirtualDataSlice { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl VirtualDataSlice { - pub fn set_col( - &mut self, - name: &str, - group_by_index: Option, - index: usize, - value: T, - ) -> Result<(), Box> { - if group_by_index.is_some() { - let col = - if let Some(VirtualDataColumn::RowPath(row_path)) = self.get_mut("__ROW_PATH__") { - row_path - } else { - self.insert( - "__ROW_PATH__".to_owned(), - VirtualDataColumn::RowPath(vec![]), - ); - let Some(VirtualDataColumn::RowPath(rp)) = self.get_mut("__ROW_PATH__") else { - panic!("Irrefutable") - }; - - rp - }; - - if let Some(row) = col.get_mut(index) { - let scalar = value.to_scalar(); - row.push(scalar); - } else { - while col.len() < index { - col.push(vec![]) - } - - let scalar = value.to_scalar(); - col.push(vec![scalar]); - } - - Ok(()) - } else { - let col = if let Some(col) = self.get_mut(name) { - col - } else { - self.insert(name.to_owned(), T::new_column()); - self.get_mut(name).unwrap() - }; - - Ok(value.write_to(col)?) - } - } -} - -/// DTO for `GetFeaturesResp` -#[derive(Debug, Default, Deserialize)] -pub struct Features<'a> { - #[serde(default)] - pub group_by: bool, - - #[serde(default)] - pub split_by: bool, - - #[serde(default)] - pub filter_ops: IndexMap>>, - - #[serde(default)] - pub aggregates: IndexMap>>, - - #[serde(default)] - pub sort: bool, - - #[serde(default)] - pub expressions: bool, - - #[serde(default)] - pub on_update: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum AggSpec<'a> { - Single(Cow<'a, str>), - Multiple(Cow<'a, str>, Vec), -} - -impl<'a> From> for perspective_client::proto::GetFeaturesResp { - fn from(value: Features<'a>) -> perspective_client::proto::GetFeaturesResp { - GetFeaturesResp { - group_by: value.group_by, - split_by: value.split_by, - expressions: value.expressions, - on_update: value.on_update, - sort: value.sort, - aggregates: value - .aggregates - .iter() - .map(|(dtype, aggs)| { - (*dtype as u32, AggregateOptions { - aggregates: aggs - .iter() - .map(|agg| match agg { - AggSpec::Single(cow) => AggregateArgs { - name: cow.to_string(), - args: vec![], - }, - AggSpec::Multiple(cow, column_types) => AggregateArgs { - name: cow.to_string(), - args: column_types.iter().map(|x| *x as i32).collect(), - }, - }) - .collect(), - }) - }) - .collect(), - filter_ops: value - .filter_ops - .iter() - .map(|(ty, options)| { - (*ty as u32, ColumnTypeOptions { - options: options.iter().map(|x| (*x).to_string()).collect(), - }) - }) - .collect(), - } - } -} - -pub struct VirtualServer { - handler: T, - view_to_table: IndexMap, - view_configs: IndexMap, -} - -impl VirtualServer { - pub fn new(handler: T) -> Self { - Self { - handler, - view_configs: IndexMap::default(), - view_to_table: IndexMap::default(), - } - } - - pub async fn handle_request(&mut self, bytes: Bytes) -> Result> { - use perspective_client::proto::request::ClientReq::*; - - let msg = Request::decode(bytes).map_err(VirtualServerError::DecodeError)?; - - #[cfg(feature = "logging")] - tracing::info!("Handling request: entity_id={}, req={:?}", msg.entity_id, msg.client_req); - - let resp = match msg.client_req.unwrap() { - GetFeaturesReq(_) => { - let features = self.handler.get_features().await?; - respond!(msg, GetFeaturesResp { ..features.into() }) - }, - GetHostedTablesReq(_) => { - respond!(msg, GetHostedTablesResp { - table_infos: self.handler.get_hosted_tables().await? - }) - }, - TableSchemaReq(_) => { - respond!(msg, TableSchemaResp { - schema: self - .handler - .table_schema(msg.entity_id.as_str()).await - .ok() - .map(|value| perspective_client::proto::Schema { - schema: value - .iter() - .map(|x| perspective_client::proto::schema::KeyTypePair { - name: x.0.to_string(), - r#type: *x.1 as i32, - }) - .collect(), - }) - }) - }, - TableMakePortReq(req) => { - respond!(msg, TableMakePortResp { - port_id: self.handler.table_make_port(&req).await? - }) - }, - TableMakeViewReq(req) => { - self.view_to_table - .insert(req.view_id.clone(), msg.entity_id.clone()); - - let mut config: ViewConfigUpdate = req.config.clone().unwrap_or_default().into(); - let bytes = respond!(msg, TableMakeViewResp { - view_id: self.handler.table_make_view( - msg.entity_id.as_str(), - req.view_id.as_str(), - &mut config - ).await? - }); - - self.view_configs.insert(req.view_id.clone(), config.into()); - bytes - }, - TableSizeReq(_) => { - respond!(msg, TableSizeResp { - size: self.handler.table_size(msg.entity_id.as_str()).await? - }) - }, - TableValidateExprReq(req) => { - let mut expression_schema = HashMap::::default(); - let mut expression_alias = HashMap::::default(); - let mut errors = HashMap::::default(); - for (name, ex) in req.column_to_expr.iter() { - let _ = expression_alias.insert(name.clone(), ex.clone()); - match self - .handler - .table_validate_expression(&msg.entity_id, ex.as_str()).await - { - Ok(dtype) => { - let _ = expression_schema.insert(name.clone(), dtype as i32); - }, - Err(e) => { - let _ = errors.insert(name.clone(), ExprValidationError { - error_message: format!("{}", e), - line: 0, - column: 0, - }); - }, - } - } - - respond!(msg, TableValidateExprResp { - expression_schema, - errors, - expression_alias, - }) - }, - ViewSchemaReq(_) => { - respond!(msg, ViewSchemaResp { - schema: self - .handler - .view_schema( - msg.entity_id.as_str(), - self.view_configs.get(&msg.entity_id).unwrap() - ).await? - .into_iter() - .map(|(x, y)| (x, y as i32)) - .collect() - }) - }, - ViewDimensionsReq(_) => { - let view_id = &msg.entity_id; - let table_id = self - .view_to_table - .get(view_id) - .ok_or_else(|| VirtualServerError::UnknownViewId(view_id.to_string()))?; - - let num_table_rows = self.handler.table_size(table_id).await?; - let num_table_columns = self.handler.table_schema(table_id).await?.len() as u32; - let config = self.view_configs.get(view_id).unwrap(); - let num_view_columns = self.handler.table_columns_size(table_id, config).await?; - let num_view_rows = self.handler.view_size(view_id).await?; - let resp = ViewDimensionsResp { - num_table_columns, - num_table_rows, - num_view_columns, - num_view_rows, - }; - - respond!(msg, ViewDimensionsResp { ..resp }) - }, - ViewGetConfigReq(_) => { - respond!(msg, ViewGetConfigResp { - config: Some( - ViewConfigUpdate::from( - self.view_configs.get(&msg.entity_id).unwrap().clone() - ) - .into() - ) - }) - }, - ViewExpressionSchemaReq(_) => { - let mut schema = HashMap::::default(); - let table_id = self.view_to_table.get(&msg.entity_id); - for (name, ex) in self - .view_configs - .get(&msg.entity_id) - .unwrap() - .expressions - .iter() - { - match self - .handler - .table_validate_expression(table_id.unwrap(), ex.as_str()).await - { - Ok(dtype) => { - let _ = schema.insert(name.clone(), dtype as i32); - }, - Err(_e) => { - // TODO: handle error - }, - } - } - - let resp = ViewExpressionSchemaResp { schema }; - respond!(msg, ViewExpressionSchemaResp { ..resp }) - }, - ViewColumnPathsReq(_) => { - respond!(msg, ViewColumnPathsResp { - paths: self - .handler - .view_schema( - msg.entity_id.as_str(), - self.view_configs.get(&msg.entity_id).unwrap() - ).await? - .keys() - .cloned() - .collect() - }) - }, - ViewToColumnsStringReq(view_to_columns_string_req) => { - let viewport = view_to_columns_string_req.viewport.unwrap(); - let config = self.view_configs.get(&msg.entity_id).unwrap(); - let cols = self - .handler - .view_get_data(msg.entity_id.as_str(), config, &viewport).await?; - let json_string = serde_json::to_string(&cols) - .map_err(|e| VirtualServerError::InvalidJSON(std::sync::Arc::new(e)))?; - respond!(msg, ViewToColumnsStringResp { json_string }) - }, - ViewDeleteReq(_) => { - self.handler.view_delete(msg.entity_id.as_str()).await?; - self.view_to_table.shift_remove(&msg.entity_id); - self.view_configs.shift_remove(&msg.entity_id); - respond!(msg, ViewDeleteResp {}) - }, - MakeTableReq(req) => { - self.handler.make_table(&msg.entity_id, req.data.as_ref().unwrap()).await?; - respond!(msg, MakeTableResp {}) - }, - - // Stub implementations for callback/update requests that VirtualServer doesn't support - TableOnDeleteReq(_) => { - respond!(msg, TableOnDeleteResp {}) - }, - ViewOnUpdateReq(_) => { - respond!(msg, ViewOnUpdateResp { delta: None, port_id: 0 }) - }, - ViewOnDeleteReq(_) => { - respond!(msg, ViewOnDeleteResp {}) - }, - ViewRemoveOnUpdateReq(_) => { - respond!(msg, ViewRemoveOnUpdateResp {}) - }, - TableRemoveDeleteReq(_) => { - respond!(msg, TableRemoveDeleteResp {}) - }, - ViewRemoveDeleteReq(_) => { - respond!(msg, ViewRemoveDeleteResp {}) - }, - - x => { - #[cfg(feature = "logging")] - tracing::error!("Not handled {:?}", x); - - // Return an error response instead of empty bytes - return Err(VirtualServerError::Other(format!("Unhandled request: {:?}", x))); - }, - }; - - Ok(resp) - } -} diff --git a/rust/perspective-server/build.mjs b/rust/perspective-server/build.mjs index 31b490c687..c188045544 100644 --- a/rust/perspective-server/build.mjs +++ b/rust/perspective-server/build.mjs @@ -83,10 +83,10 @@ try { fs.cpSync("build/release/web", "dist/wasm", { recursive: true }); if (!process.env.PSP_HEAP_INSTRUMENTS) { - // compress( - // `./dist/wasm/perspective-server.wasm`, - // `./dist/wasm/perspective-server.wasm`, - // ); + compress( + `./dist/wasm/perspective-server.wasm`, + `./dist/wasm/perspective-server.wasm`, + ); } } catch (e) { console.error(e); diff --git a/rust/perspective-viewer/src/rust/components/viewer.rs b/rust/perspective-viewer/src/rust/components/viewer.rs index ba7e61edcd..b57d4f43e0 100644 --- a/rust/perspective-viewer/src/rust/components/viewer.rs +++ b/rust/perspective-viewer/src/rust/components/viewer.rs @@ -470,6 +470,7 @@ impl PerspectiveViewer { sender: Option>>, ) { let is_open = ctx.props().presentation.is_settings_open(); + ctx.props().presentation.set_settings_before_open(!is_open); match force { Some(force) if is_open == force => { if let Some(sender) = sender { @@ -477,7 +478,6 @@ impl PerspectiveViewer { } }, Some(_) | None => { - ctx.props().presentation.set_settings_before_open(!is_open); let force = !is_open; let callback = ctx.link().callback(move |resolve| { let update = SettingsUpdate::Update(force); @@ -495,13 +495,17 @@ impl PerspectiveViewer { renderer .presize(force, { let (sender, receiver) = channel::<()>(); - callback.emit(sender); - async move { Ok(receiver.await?) } + async move { + callback.emit(sender); + presentation.set_settings_open(!is_open); + Ok(receiver.await?) + } }) .await } else { let (sender, receiver) = channel::<()>(); callback.emit(sender); + presentation.set_settings_open(!is_open); receiver.await?; Ok(JsValue::UNDEFINED) }; @@ -513,7 +517,6 @@ impl PerspectiveViewer { .into_apierror()?; }; - presentation.set_settings_open(!is_open); Ok(JsValue::undefined()) }); }, diff --git a/tools/test/results.tar.gz b/tools/test/results.tar.gz index ac00ff45eb12bff84d45879146c923bacaa282f2..96db7dee2341bd81b25d7b5318227ad6dce77b62 100644 GIT binary patch literal 159494 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

)qedg|-!FRY_AM$yNGI2OnTP2OOD5(|Y9m?RO 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 F{U0_blScpm literal 159495 zcmeFY^;=xQ(cy9aj}+}$-e!QI`R;O>^70fIY&56tY4@4Nfn zySsnEeeV3!^?B-4S5;S^sqU&n9EJ4i-w%4yHR-q`mBOBO?2U}KD$u})|2mi#vetmj z8l`HYYNlpl<}9X~dQ*-@kj8#OymInxS`)8HRC?^NcT-(`JN=^kCyP0KbIsK z1a4;)Q%e1Wb{BNKJl*C-v^&VA5NO&h73KXny?CXAX$Ew#o#=m3iD!)YO(tvKZ;7)v zOPu$EssD!uczTSQvxn8p@`|OM1!vZqh|27;>D~@pPe1OIIqP%)fB(fn{&@*I%KpQ&I@Oe`Q+Fg+Y-S^Nl*_s5Pu@)dz?>ID~)Nc>AUTwImzFk&gMepgx)!Bi~ zJRUV^Zf;1hKmHAel5^Ld5vH_6L6i|D0%)>lJg1%)X%v}U6WfwoWvIXcV$?a2Iz%6% z(GsDQG*3yq`MkI?_S_@Mi^HK}lVT>F79;a!H#J0S2MomZUVHY~|0qnbP9Z_yOFwjH z;L-*v`t-mm#mH2g0ZoE->Th<-;vMgJ2_-UuQ-IV~AIbuy_C-XI- znI&3=!P4A8dpkaH8$|{ZJ)L;j20b0@f&E) z?Oo{hcKwC8*~S`&0NQ%{H_r3`go8z}(f9Ey+w@PueC>FD`@BDGh*`4o^=z0Odb87! zxC2=j>enx-aLTLP0+%cDr9KkY@<;ifa(q>HbCNwgo-$FL;6IJOmGUa&=UHuVST=?c z7PUnDV#>Q6cUpB(+dNr*tEhEjjm?**q+uK@al&(~^C_LUpv$uk^iN<6{>qxFk-nMm zpkwChXU#`q#Bsj0CeX$64JT|JY7#u|S!BkNpzAfRh{yfs#QNQqounYq4LjytRY8FL z-e3!tH%oRAF#VZ%%&r=`ZkmYc^vh&`;ZDIyBP+tWN>5{-zin)!TE+uS@Zsa-yf2wA zkHUb$UBvw%Kc?U_^>&FnneQ0&Hg$nu;#vgtK=2nJj$h7LP{hrxNx6B;+1j_o8SA7X zgJ!r-#1%W0thc-vK7cp%%=cUy4>ID>O+U>-WB^oX*z z?~o|qH@!{8U&|Afaou`EZ#UOp-q0!?S3U&;OJBgW7f=)iz6&q)=Ga>D$n+sYCl_KX zx^19M+cp-_9Eb}Q*SeNW&+R9x#^V;!Gy(t7sdRRB|DtrbchN^yl+ph_D#;FwiC(## zyR%m6{|cg>`yX&zGhkYx|bJ@WCv+*^78b8kWZi_arpd=7Z=`Tq-i zbMC+1mf6ba8CLCj8^IU-N-2J2y0~i{`tk*EU1dr!uUb>k1+vHd@ zdjJ5Yo9>IQz<8MMgW@n-K7D#o`2PUQwg@jsKGX!Y>QHdG(J@M{hUP^*AebGoQJ?!t+m2oadTiI(Wk6IIZs{Z{}YtbrJ%|V zVH7R6eG&Z%sKo0UpF1GFt*Lvx%uK7^ps@KfAYKub@Q>;z@33S$v~xZUm^(*aw3TFp zd7jVlMf>=d_=)?c_}`a!B7ZT2s7(s>{CfDi167}}q_~xN&Pe~k$zL+*h{(S%uPj?M zhua0dCBT+moPo=C4y|@7f`z2YOGs|Nh=}?Zk}p$qw!Vbq{|Ta=BVA4p;2adZ|E~pY z4cAKg8Ll-F!JH>R;bkO`4WT&t z=E9J7MASE`n5)3qmWMLc0bc-RU57hnn_^>hp8b6rc7mc}RLF2||IqRN7n|0~Wsfq`EmSC4ydDdhs&NPJoy}dG9Ova5GFFsMn zohlzbQQMs=KbX(=HLEOabAQ+5iOTWseA(=U$y3|ZBo#0FT)L>?{@n;|7UDi>u$?zU zI)Z)9nDM)KMXOKb@yM191<8$@q0$L{QG$r~W;T^2o-1nso5$GztN!0k{K*RfaXD{H zT<%m=VY8rpUtCwwItATx3#q#)gfva>?7z}l_eowdtL8T(m_j?OXubV0baPT^r|exOOxm%AXf^Yg|CEBa|mrnj3McL)H zIm6R%d&!UGEt9*!Iu7u<1~<*qY?bEIbnyVYh62hK!C=!ee?Bp{YEvJz^}P$xrR*`g zehqmAmll{R7kCM!&jk9CADVLdt)=sCo!t4Q7ktrbXd_mpw_GTdz}%h96sTNrr6>De zZO}@lZq@w3Uw1surYNFS12YJ#dY?(3@N#*Z>#_k3UhWCX3gWGwGgp^YKA(NzMwX(^ z8dee#IOL0c{Xu3Qi1l5GC$7LhY0iTH{%_LM4h>0}Gm#|b?A*ZBgIHe$DJR#H36WCx zgmOnVWwdHGO#1Y53z=r_o8s(9C*F(P`v|OkbuhQk*6K&v>8e{N|GpM#v!>YH&*8DwE+9)Gh*_3wMVnWW3fV_rFsu!Ovz@<>mSL?zc+T8Eol) za+Q_vtiLQ7P+7sH{Uru=3bT)#U)F9&+Gp`F#i;L(?%BU6JA49uDEkT%Sqwf`5|B;g z3k;LyUpzFkn95w4g_|1gE_couoQ4a9J$CT23s4dt%V&IW1QBtmZq#!AZHKQ%6+A80 z3f}Ic$Nv$DVmLS0*ebujIY!J_Ebc%UmilR)zEf*Z*sRj;CBrb(7|1(P-*Y1(HgZhxwM0s3$U`@(?mx?5j>9g@h z6loCuj$L5CEZn@&s#{4tD3?;5a+iZtPc~6N!m^Y@7#FA(kEfL;=@-m2gzw&_>(^f` zO^#OG@2)Ceb<;BhGXy3uijg-5(*FF{MH}nzbUn;(de4x<6C2C0KgVYkqSYqY0WN6K zdSn3r{1o-&AP;+IHCNM|L#*=e$w&IWIYVoBqdPlk1Un}B_$CCxn8w)^JKsmr_^`;& zQLL@gZDe%n4T`J&zSdhifw1A}4Jdus@Oh$&+S{ETYPqCgs=rpbH+r&PUS8h4PdYyq z40%4(crJNVn0_evG@q4h@SKOLOK@L#1X}EAL#v7!ibY3F5vQIfpF69-d`Dg z#)^U+2W|ydBAfH8Ea;#K>bDy!exe1n|Hzd{2}4_Pomut9WOGaL{|2}6;u8LMhJB0w zpW($(|DX25m~o!N?(kJulqIC?X|wGaX!bY9eyOLkvlVhCO}W<0ddpd<_{6~7`Wf#;S*tIV*c4Or6`ejxcQ4*Im?fwT5RKmp>K7%pI%?bPhV z{j%KNGqaur6~~#1%#!T@gRi*RH$41~7xdC>l5$vZj+uTx|00XF!x@ZXxw}7G)uH}- z=9RG(H9oy*VwkqzXI=<9RAX=IM^{CXSI`>>R(0cQX$o8s`FeP;Fx2eb4`LrG18t+& zq98Pq;Dbf!!6udrPTh5vPGlW@-2}Iu*2(SJ^M>_KA4-eY@&N%d;2f7($`Opil zX@gdnI()=4?PTsYQ7Ji80bR=~H|T3WLz{D$);D-M8)ny9J3+NIkZnNpj&~(;sF@*w zMo^ws(&YBV+1*2GZW_awy9Eg1E9QJB-s^%J$Aqem9x*m8E>572Hv!U}cZA;kdqFc6 zXSBqHSUaGfnxWeoW~59Kt@eu=AKVvTpOAWu*s8P|jS#U8H|`c}nwI<)l}wv+`TL#! z`s<VHxd}=S8ls-wfgkm6AV-^ zGx_KbrmhOEL-u)}Jrim?l0MDzy-Rr@W4Jopr+HGg%Ncw6!#5#_eK?oKP=C#l8Jg2y zI5K(VJwktVNG~TK!8vrhzHL4GM3`dg5&K2C%U)}({(0q`-cE;+*vI?^*n6`KUpNzA$62NUr$NnxW(K|Uh*3bOSx z4}<98RsLdIvq~^EZT`1XqqGFcJis&yRbyd*}EX+ z_U`iDC10E9Z%}`Jr4OwEj5h35W@hhxHt0s*W)@^`0may7gEH(beP`}&v6!3^1{HI5 z%yYQU9;lBC_!NKr#jL(o5@ChlYFH7s&H3@yH=d%TIaIXGDcnq1mNyp5paO?2>(v>$ZmBB zbZdTj&(`qm)kL7=2#p}1U<+7%S0}$hyk&L6tzmCaKHcW|G-tf8%kOhYu3J{1aNS%( z4P-1-xXwPxny2=B6thP%7kU&^$T>Lt$nTS1qHwKP<6`CP14zUNF^~37(BKFg6-rzD z-J+MEU^fd)+9QbYz)|hVf^{w&7j5rW}s)lX$lgDPDzXUvQ2 zfDPYr4U5(8!_sP{ho}0zV>5 zY_{>Yj&7^BSoVU`$NO^{({Q^?*@w8lz|(Bi-e3=XK~Tt{HgCnd>GSBi(1hOkW9$)ag} z{|i(MhIVJWk{>G!YXVnmnl?1cYgUL_)$N+Djw?q?yPw=FT$1kuB|V%82akgwal)C) z>Pww`P);8l^%3F4-)7u`T$QZr^5*Z#pEv2J_IdQF8t4xklJ9S95Tk++t>0WtySCa~ zIM`+f!3G4dL12hEuP&o|E63$R10f?ipRVc5gAL+1BKi^)qhE7B<ZTjVZLujaL=3ApImepZ2X)%} z@;S#AZ5OlkgHD8W==|#7;go5Y1MmBp(=1DY<-Cs-Q| zn-$Q5)$F^08v}*EE~-#96GL}=`WEC`#5~n@fHj%G)1ukyb6+x<4};WJRmGf3*g54s z)k(o;OMicT2pPB6`odhj)2Od%qT$^R0e@gPdVj9J+ynvA0n~Drrf5zezUQS~3!LYW z*~fh@6alJ3?^7YQy{?@j->y6*Lh;MZvIFZ)u-ld7y}UT+dZTAPz}Gybc<}Q4iQ)b< z*uDx=0MOv;w#X1f|1}bk(06d?rkrLj#t1^ipll4WmeD zp4)?Rt%U4lH8bhAAk%8!v6L>Wrj;kd6S4@JfjecoM5i(WfN8s zz+3yIIR97v+7mnhP8*--p-#CRf3}|DhJBisq2n&zSy1j_*?m7}h~&Osw(CI^NRZ#R zYzoR}1p|$vuJx2=Tep~NA@S@flX^&KkT<4pr?Z34ca->c6oir&$GG;?3c6K)B$AV z&93Bp$ar@8fqkErvQ9jw#_IR0xCRQ;GPc~^stlhqNk!0>&qtL$9=y*>lu2P+Mpa*0 zRm0|GL*F5U=p@+rZtR8>k6%?t=*-ugc{Qv(9~-qli0$O)!U_~IlKA6Wo!7pgn#}iIHm&E34PWvb#JRxES-xGwnXG&a(XjG|=3q#jzVwjj z@sBWHcD4G|cpWafnvs#fzf^pV=$tv7qk$gIlHk>yTe}L?&k{uI6rpvHL}Cf(Q0Emi zrXq#IIR0#Hl}4+YsP*A^tM&IU<`m2BkKBGbp*nQv_GeQiehs!PIj3xtp5N#ZS`0DG z3QW^CwHP-$z<;kDdD)1^b33n2)%IINZ+cFn)ox-1oSS~dj&?CE_nyq};Xs)Iqock3 zxe)NwPMs9zdI=TpxowTL$urYphu!IE95G=|h@AbG@kfI@d+4EHGqlsG!Le?TY{G8- z=4Z?~aYyo+w*4LdJNuQjyU8ZP)^ln1`R(r1&Fao`i5(wo4)# z%Mmx{eB#{|z2jeHyC!V9brJMyfj~|Tu?BadEaPqeZ2K-SMIJlDkf-Gt$m!Xc%|=!Q zqvQSuEA&~bhM^lB13G)#oU7FmFRj1@2HEc^H*eZ@A(noxe)T6$^xClm>NI;8OzrnJ zbg)#TQIn%m_oGoGqL%bAj5$1Vd?|9;_II4~cRcZ5$%RvE75}Wbm$m`vQjaT}}o<^l>4&iV8Ff^!jmQ|=JOnwy!??6>XSUU=zTw{sXUq_PELBs7fl zSzg#Vj31iskKKn*__v9BlKpsE*gZ=OogYTq@1AFFk-E2hpjr(hyYWY!^G81MmznVg ztda5m8E+S*jxC{xDH({RUcJNH`(!Ef!+0#w*mH#_B9BwLR4-wSWiD6Vd;H7qGO4j} zFUkwEh+bFOKc@*M6VCFe=bvqR27cmGg|4ua5s*)+Ejugth_I|=M+9)np)^@5b4x_Z zFl;7K*Z8hE5#y{h@WJY0x$#feQjMySNP8 zrpdNMcd1JKwR6od&gAXVDj26W+2XbQWP!tqeq_*{GC@-+%r0I5mi5F`L0h`PULD#& z7Q88z4(HtdjilZv)1~9$4TGq(34HU`q+5r z?9!8Utxmchs*5bBrq|9A!oI{X*@Mtg^subi!Un}VPT4aMU7!0erdt>jI~0$*GTpE0 zVL9DTE<%PKs{MksZ@29kSVLUQhuK~LH^qOPu#t3b;S1+8bMb#Y1A9;*(vRQf^&WZ8 zs~ZClSyV#C)D859{rRHbjjkT_hOcJl25KKP=!t?Ia+dQ`p_dO z=$SPH`p*atTbN+->T>s%cs>1XQorvM0^Ia9j$oq>u7SW4`2Mt;>6yRV`~GfLkPhEJ z`R&1^AMe`zWEYol)1~Wu&U8In?0LLj%7(tAG#4kwIsUEz(|l!V89dsSU_+brCI6E- zb@7574k!TiH_?{?Ugu_wM6uF_kZ!oG}E7vs=g zcjx5l1TruOMck5YIO5I}cw0B>RIF%vm5nLT-I=}e=9aNDV$MVet!r}b!Ac#)`q=

f8M> z&3Kd@#+f?F)3hM|1Ta{(b~}0fuHH1giW4qtQW0*g>eYRRMKe;p#|BaVIuWz33q-Qd z!ObWF!Av>hJ;k?HoxuxqLA}rRe)`6(|Z z8FKW875BA5P#O$0ujWz$-cP|^RU~W2#C`l&!Cn|hcgz@PTO@05gj<*21Xmm#`E zz3uA)1ouEzd3SC$moV(SvPEBvHmIqm0H}X96-Y+vZcbzxd#&8HCi)>-c07q@qiBv7 zIlMs1P-fVI56=)Wit2_bHph@0n zJI?RIN7U}(-|kLu*lO;H>=MF{DYX+78Sh69i__73dKPVaCDP_E)Fvd{_EWe`NT}@q zZcGiYNixV0F2GSKj6`Al4|8a0laWor0{Vc zjGh&s&+Y8XA*ZXu!lzE;4URJr)3oE9xeDDKRQd(oC2=mRti{<4*-)2F$v*S#Ra9*UHEhewz#P`kT zK@a%B%`Ubytg6p|4s{xuDIm=h9SwwPQj#e#gPH1c!p?XFNvuGz)(7~%pR3B*!~j#a za`YuzPi2#8DMFAtW+ob{=8}waK^rMR>}Cp`ew~ECdZnfg(+{@yc1QCco!--Go`K5& z@k0AFiW@>XmX1>V_mKNJR)%AmzbjQF%XY|_N`0B@_9UX!dI4)W8~77-V#hy!4+&Mr z1nG9XZtDrXbZH;^5Gm>C{lFvNU?DH5qlU_=TthAp?{EzI)SB?j;qVb6Aci3YT>ZXU7toMdxToS?i4VHsv;nJ9nh5yyisy#I^itY}4;c z+J)9}?eE@iT=Keys)Nab@SMWO3odHf57tQ(hS@mIZzad+^xXQ=t?HWFq(rt0sA}jx z5;N6s99r*viDXyaE-0?_=FSN5$TkC`zn+jQ6l&cm_XNDk@=6N#xI@UqV`-Ta=0W9= zB3Z~c`5yZ|Ksy>`u6LA#Fcq!IPC+qBT{s~ZCqK~H3NXT|3%N|SZKc-{wUvXHMoG0+ z9w9C;A)2El9_x@AV*{5z{t?Q$_1m5>5KE8K6nAfC?;+9Bk>q z|B6(@zOsZJCEeY=UNJQ@gp6%d5je=&^%>DLVt7h@$(>h63|zYI%aDs56`RI9%gza> zM9;G!ux8iral*VR?wXHgZOJ*R^yIB}!^r0^lqmMI>4Tv`T|4f_E2^=0r*W4Bj9o>4I8q^B^CjKikeB@@g9QfakAQ60uAMA;1gDUj_5c2Jb8kNfSR> zx)k*;28Rg3pH8n^mG@;)yf)WgeI$~XQ~Sjqq4*JtED^~!)` z*DL7<=wd3ObP%pD8Ih&mGQE=i1|0a;RRw#<2Qg3oa70sfdFA3Eua*yU`MsWSh#z!IVEFg+->9@{>Jc1q~yI%Bz^-zYCPof?VsHV(0ZK zRaN#d=MmX`hyXfKI0wJ`f?d*8c_iW1LK}yGKa)@4mTij)oba8Uxqi9?u<|U6ysEY) zeYHGjd7%6LO>6Zc;-7X4QbKqKz3}f-O}B7&lf^S%?z)<7SeHd!SL>+EchTyo$6u;@ ze3V|(TMl-S(crnmb=Ua}R=I3WKA5z{=z_b%q49M3{MPVD> zhLrpJ_|7)=(N%eIg*_eHka0V?tEHyvU>%iCI66J)NyZI8i;N>{Hr2%O@Hcr;7Z> zo+m5j)RsM;He99jsEXqc|^vA7HEitOvF-#WW;6hK?O#QtraMz*TFl>y*F6K}2^-Mb&F z3kB%m?PwS-gh)q5CMJyX5_>#c3oehsdX4GP!R=^-F4)TT4BP>{@;W$aQ3(lcH&i|t zj)7Num>qmTOO6}uf%|3Apzk~WtHM%y%^wolP(@hn$0(5OVVB`801EI$LGO>b z%ZCY1=QK5`;b#7lAID8qXCLceW5pDkRkZ8*Z@pyiCN@R02+ zx?Mk|1=d=z?95vFTU%<5X5Cqyah=3mOyiw)G}I6H1*R4gaC^j|WTH;IBQyU535OyK zXh!sDzU$TeFG12nn!nM9@^{TCqLZDYt1PE2Zm`zUZ!5vl+H$T4l_j~IL4`=a`xaFc zdG2UPR)szHni8@-Q0Kl7jCkT8nee}dt0xk3+uL$B&lZQ*nm&Rz;cxsK{klF|96?JV zk8>5-`^Qd`sk@hA*AI>PjDWY@V>(jh+T>pmDY;a5ID@+dWrn9zyVk5JO+a|THbU2C zG}mSr*P*4q_%b6WjZLo2o^v_1e@ckhOGw%`$89ie+Ro@S$`gB`L~gL7u8vpwx}>d2 z%7~;9p|sAfG*w3;*95svS{Lbo@EWb7x2Ba*=qp$;NuzukoN{pV?g*aU#9z{ zD7!l7(W-LqW>$0ZNb2M-P4wubj#g!Rl8kZu4{>#bZhai_0QnoE3|}H%R?>^ zFdyK)dsh<_3Y)2poZ zs(o6zWKiwfTcoggdA-R>7c*8TxLVUAH7Fn&Ho04n6_#zDas(N5a_4u`;21Ld zg|jvvQ`1F%Q!DhimTdiKOYfSh@!)1?Heh%GR26T(iStOumPkw5Xo#sBlik`rSh6(YQ2$K9uFM=7i(7G~>9I z*J7#v_VNIbl`~v5A9|?g{;_D(pTIMt2((y<`+Q|3B(L|sAGRO@n&%%vcV_&m0}Hu6 zabz9LSJ31nze2l^#$K`W>5y;mNQ=?->iE>TQ8N*TIlG(Xe{;s|TZgJA+0-daCxaK9 z8?s}rcBmy7cD$gm(rjy&oPYD5PS2a2xFsEYcO?e_Al7JZvxeqb#%4){kuY*XlhX;L&~$_9Le0v1xYqm+?&HFZ0} zGBLr8^CUyt@~c5@c!&|M(aJldhAaI)KTE|P=Ee4hi3stG2=?jW!K3o4oP4TKGd83% z!wM!3EhoMs~Rd}0Z7&yagK!2p;>wsE|4K#Q|LY_W=1+QC^`t#DGB8_#4 z#0s36ws?ap7w2gx>odltXE-ORZ|`U-*RoWH=lnWxQ)nM!)loZ%B97)bUSm-WPUDd6 z;VgF5vd&{-i$TotFQDxCnPiZeyyZi1fCakl4uY zS!#Z&FiW~#F8BJg+$pr-o!p~2DK3jK6~kIJY#S*#3tedo;1964>NX9>3do_fIG{hhFs;bvgDs9z2YIn7%cYZDeglX90n5j?$ z!|nMUB1wEv-`Ym5Vb%h~hJ(k+XzpUM0EBj5r)30X#Bt6Yn47|yjd@D!j>{LOmX`au~uKNSbRDp7$r^2EN5&3*(*LXF@ycvAlKN z^2JA~h{ZzDti-ZPDxy9Um1NGiBze;bXGK2JA~#VQyBcR38jC0#Dvv(ZQVqPB=49pw zVJnI+nXD0NMba9Utg;QpUSX5!N28mb$KL=+E0Jeryz;ta1J_UIk;kw z0f$Fwb!?$7^q_nTxp8x~-TBQR_Ve0b^NTG#br)A7MtxZ2QA--X*&A&c<7~XDCF>-H zSA56F>0oINztux)!nMpf0$huESscoB%)m%}+u<3t5#FnoWZX!#MMc4qmMx10`4vX= zOtMH?3MD_LAk{!Ztuvb*gzMc%b>O@^8Hfv?mKaZz0}+G7m83#1f3x2TCkBmA;0iZxaO46R*zZ8WVmEqs)-S}ZEOqmFfLY#BtY zO7BHqgeYM#O_Y*8Bel;gp89ZAy^g05n`)qGKBP53u%^y> zPrsOTYY3oDQ!Wx-8A5kXMmun$tMXI%y@mABr~=6Iu#1RIYb>gu=+)Q3c&9h4%?YL~ z;fzsR!CIB&6M*^ff}XDF?p>!e>pjChWxq>q!Y= zuE2iwRm5bNan#Fn7CKxrR{c|fB7lswr3_HX&cwE*EHc7i!0wMI(^QkY5s;FoX(0KL z3`g9Hm0}LBq00TyjQs+ARo7I*M%ALcdCq6msz*31vRqS64Ibm1&Yl?lQ^;Zd-#nR3 zB6u8e^xAM7LEt>M+S7pc|B4POVC}C|gvefTMJ&z2id;=OgeFlz(>cq@+nU4N_IU+s zJQ|E=EC&DW3H2)ZT(_VRt#ShVu|qy>ok6~Np5&8h9m@(XF`Yr+3c#-SBIT~AdfUMT z8_u3vPjArrs65MwY)VT9LR(W-5e|QK?32j6F9u8BDz(Z>+u)q-kTU&>jNicN71?9W z+{l6ds#8?GRqtf1J^B^>wxo7jEoAYSzEdu7iAGsXAA%{j&m&5vPIi zT@cyS?WJqSX-n(8>+1ApEPHD_x}VJk9*EL5ewI9?K0R}7!!!nCUI){Kom_Zy2E7)R zKL=c&e+Z$b`xB;fz5eJSm=>-s6we$l%B++Zu%ayXW4`_R-7tq`xn-^cmeOtvMOKbZ zF>2K9j7aI!%rD6Xs>W{-ZN+X!N|^RVn%fwIABJgD+|}x?T`>oH`Jz*!x~g38Wnp(7 z9A6Wjbe_0{Z02VBD56!bJ)1W+HDMcJyxy%U{9&B{e4f4phRt3t!nOE%U!nil@Tql zQL4;@wvCYezLQG%DB-os?QD~m2H%dxuKCrxgnWKS#ms!(KY$OJ_Uni+K$jggi@@N z_+9)J&Kw{iok)hT%-Ig3ms(#mm={mv4(RyR*YV=FjOYL<9$D->22hobdP9SWdPDL{ z$yB)P`QCsvAV1;h`pRnj-EI~6YJX; z@Au?GUWawsKbxLb3gYOZB7bC&6Pyer@Q=12jpwfmw&khK;?b6UD{AZALY?+X_7I;I zn}=4dVF`j$0OzK2n8YPkG63YEWc=1j_t>x3s2CC45g6MsIhjLW^1Ss?wR8Y%2`%gl zDJ)&M{+4n2B_f$QcGh7zY|G9Xa{+VhM(3Ly9VS*-crKUx+d)bcCsV&P*!@sEv&I~Y z#@TTDD$ZT3!`neT%B8A^Tz|Q2) zG;uOaJy7|c?D79g>OEDSL7xumdxEMK${IJ1%&j`4zf>P4Vg0QWetYxc>EeEc(wGB7 zdz?e5rJA@8tg*;Dj~QCt3}ffD|32#6aML+X@W8p#x^cs~BeaX_ZkS?uRndu5e@=hp zR>d@gw+_9F=ww`y^<;a$!)1WbK-1>IvSC5U6bUP)66aSl@SFzgM(GUz1PRQBUb@x` zvN)^fZHUZ{-SXFH=62oLWXI2C^rMN9G74tJAC`7suH$FbEN_k4ea)XC>z_Lm&={IoAmiR~<0) zH3|Kf=X(uR`AyANGWQi`#(|!Xk(Wx0eWKkt!yr!B6ymDhZ|)0`U)|dK(%Ih;UE-H) znjH?8;?6N2fy>V;x@&wkma~{4X92Lp``|qH+0GbPIMN$*J_?8(=Jj*!L9LS!#B1r^ z%~>24u?#hwxZwX%b*GK|+#vVM5*YX6Ls7lz{hp%t@ic?70hZa=N5D)rA1&X++7*l0 zK=qGMZH0Ap%Kl23P_1N?b^tcA?pXP?t(lCbu;WR2aPn<$wairEA;*H~5gQ%Nc!FUH zrF9d198FzD3gs}pY^+uaK|4M6k-}KL3EG8&ecH54hz>&Rl>*o>w6Op7gT4cN(d8K_-448ya$p6slmb zEdWoxJared%woRYnR$G!mtGdJfu3;U9}&PP8-g4+kh8_*)7Hm6RxZX5#kQLTAa#_; zSj1Z3z~+k5N@O&XYfoc8H2M$WEC*24F3b-TS=2_~!!wo3&lTCiiO}6jJ5jwE!qn24 z&%b7ln(L>RL2sbppZG@vG0KF9go|Bpasxco+$FRUOAFKc80OO1cFSb1!n4aQr61EM z4~EIgsf8kfA}h+;-J^Jl7=`aOJF(VO9)O zN=IlVerTYgG?SA#NLQ_$mbOlSrI!pcA@-tD-GH@xdYM(0ER`LNrTB$@p-Liz{F~aK z&TKnZ8d;}hG*jEN6*zkN;%}SZ)%OQl_!4`0h-udf=x0JBSuuKO=u}&aeXQ(Bmy-Vk z8Qtg72q1C`te<>Wr+HJQ7^;_tOoe7yGMaXnJ4LQKg-Qx7xQeP5;5S?;NpUkF{2_oM zc4J6Vjm|4|duSHcD8Oyu<9PC@>fk7)pt$U;1S}kDA+_OA^PP3gni6nitDK6>Bt{^6 zX2v)U3$PGG82}rSY@_$H`r)QM?r3(?8qPALf#2w4+l~qjlT7XvdpRQB#hwr&LMQ{Tj7X3%`W^jo)877lr@}TONYaf;%3pXDrP>j_3P_RYe>@p^YeWLTcmoW; z4M!OevLQjpf5Spg93Jpac+J@`gO8aM>Gs}c9EoQri+!+cKeLjYJUoC#7-is{5s5O! zo4P>UaGZhKP7(wvgk(y3c=~pDryxbPx5);L2TP3#`Z^@Rgc_n$b!gnkf3gH3aA}1s&rO#hg z9K>iURMHqx#<8nWjevkBve6Q`Fve`0+(BBQD3wN?Ou`2d6fs;Qk|7LUsn1q|^H{o= z%7d6@oKx)KPC+zwxffBYI}2YJ949fUgpXAMmr87!1Uw|SF%U7sxWc&x>fi&o+Q~39 z>au~|N-5Np``oP*-|biamQW)mzunGF7wXhgBH4&%8xSreMI%E6VpxU;Nr@7O;caWq zVIU@krm+#KPE=pnBJs#48?Y${x;RSysvZnbTntd$4G>BqMaxG4Vwi>-(~1&&{d`3~ zAIk65n?(BS=&ed1(Vaa%`jrQY}=MpC=W zy)(DJX1greTbFFLzl*FA@)pjx4o}hM*-G}otbjeqJRlqlQw}zfVYu--*hIKSA74_1 zB_xE2&qwo2%7-MwtPi7?w*T8yAK{gPRbMsi6Ws;5`@>wD9Hs6OPyO}%+?xe>exY?& z^qJWIWi&AbYEzCSayF8w%k4Ub4-z6BphpUkZcKeI*h5%5o5;e$^EAc$zb6n@3*joQ z#6Voc-u~4mQa-g%h!*zjhEFW!TWaf)2f78>=@yb4VIV4nag~l>AgX=d&Se!P&@q=< z97>}YJ8NOM!H`)le>#F)f}9Dj%ttLiomQ_Kq+*U6Y35cZRayRhJ)n0t-Ty53!xkKU zegkbJ(71#04Oh58r`I8I=odhdoaXwSo5$gPpBWtnu?OZrL4{#ei`$l5 z64e{W1KX4-jRqslV<>>8FT+`J{Pg)~g{wk`S{}5&7LB#{pL?Qe{K8l~{6bGjw)eZN zpU-_g-D-3WZDA)sezOrm1ISHsFW;HYTe^+=D}db9qHP*x<^9fh52X_3l;klr{%-xi z{naxotOX)Y33vKkD6IV6lYc@blkeZ&e}h#XxGoYv*t+p?kAvcDMb}u1<-(^OfP;na z?fn2gULIOaMWAz|kNtyXeS3d(7e`#6#pT(f&A1cJ_IWIU#Xe|vV{Kqf_ydTbv_zdp z5-{j+G*P}g2M|4vV`7*uH-5jjEyZrHH_w}#{f9WdTGc$;pz7~qH&TMf_eoyTCWETI zM^FudQ=vuFLU)Le)n*_J!=`@1@%{Y#uaa=9ktH{ZsIu^|M@K zp~4UOD$vO2{h}bGKGbU1i7=9#RaK*s8`fC|E47)q(52}}6$JiFxRBYDqHhu)EFeJF zq>@zAR(zDOUfs!Aahwy(k1L-%C_mbN>1BvT_b)%&?~g+;XS9EQ_3b<2tA74h%B}7UqsWg}C8U0xMXtofgK(6bhX{W(7Db-i&H0?Tb4||LwlYu=*<%6pRC{%s znuD_UD1RI>NXk43WY)7yg;=|H=!#*H!;TN6UlW+8`@i$eV?{E3kY6?G;|V+HjGWh- zqs4w%sz>|t=!zW9L#))_|5oY-Ae(QLyg1yIOla=y(S$@MKb{_|~)j@s`dcAujBh z;}!VS=fv;1h5dH5o04XU=9j%jou4hWf!HYOGhA+aP~l^6w`K2z!LBZPnRB$U=>H(= zEu-QHnzmtrdkC5k+}R`qcXx+{#UVJs3GVLh1Xx^`#XY#YySsak;PUN#KhJs3`<`!q z%<1ags_CxonVqh>`kF4^WV^Z>OriRNWMAFh&I>8(BOlI<>#xHLiph;5v=;*H zxdAa>6u(zRf|rr!(Wm#|XUv^)YTx3B*WTwSRd3|h@6%uZ937A)#Hchu>%UGmi4+|C zXGQ;})*ip@o9j@q_3oFr$Pe($+z<4%G9CHf1W7I~y+Qj_@bqnp~Kl$kf^E^a=#JG5^euz~#U88Bep zdC}#kzxNsGer*P<^&o|=KWkPW3-3b?|15}n?@jXe33c<%6V6BCC}aTPKeavt_pZ2t zDFfO+-@gF<;u-sNpua(aVvm^;JhJZTLTR7X#K}K_{=(rZ2E+)}QU{&}r7+tkS2b(!cS0iGWg=oZfv_7yb|8r2}I0b`JcWSYC3U z7x9uA=mV7SHkqt!4i#rD!a`t4cEsNe-OB~R&T*{qASrvt4J}f^n%t0OKgk!b^-*Jg zdL7g=F|5O2u2?{x=u%?W#dL~F%5008Lt|HhLq3DlGgwIn)l3z-Uzf-cegJn z;#fcWS(BG+t*%LtLr4e@kg(^l23p4X zLE&$Y@guMdYV}cJ?C=CbrYGhQ$n>uWMR_ZCx`}L8kWJ^Idb2ukrd}m1F%N3({SVw_ zt<(@=5muwNnN)7f%7WfV8NGPm7vX<(T?%&48Ewj)9IC7jSLcNCV3(iGWp{?BL zDtvS+-+!(9&6f9;!!y_tkOa))*q3&O>mUtKa$I1POc86~syOnhTzeO+XL&lbypTnU z#W&MAA!T|3>?j$Zx3(3O3%;= zAtOM(BB5U%AmChnyS8cMW{N@nSHcvtJ|iar6AKbrr!q+^l_sIfa=hlxq%G6qGB}>( zWxkI#Vk+^g#hUqn&I)Ods#iQwGqeI)<*ku*CiSMWNz5`wq)3&(Gv}ljtf1n&o9G*o z%_45KG=Mgvs)SmataN~eX^F-KR)6L^jRY{c@HJySB!8@#_G6wP3Uqk3R04)N(MCB{ zGd=!pPeOZ?)n5N1FNUEiV6}Z2L*OH!kh&aQPL*C*(s1B`Z*>zYEHn+;)1wi9w;ri8 zjD@S?B#=-8@p2|f!D)k!QB|{+pA_n*iz1+$$(?HWYWBw~8(4Hf3m){!M8z&6E1PvC zc~FHmKGb2F=MaAdquGU6O*D?BneO zt@6mcu-@+k7X-_U1P~~>;9`1-Y7`?u>V&n!lpw7xCy-rR>9`qhDXk8EDlsBG>!wFZ zqkp87L!OOsY6$dO%A#;P}Q#;t)eFtd(QkFB$V3!i??SIWclJ<$tABspG(oZ5}hM7@S zDDRli@90F5Mh-;{=k%awE=4FKj*7xmAV~Fwat=hZq^(c!uAom)dV{uuwAjXii86;m z56?O;VdGbI(HHs8E~8=UR=3BC$_X3gbLj)NB{mU>3Pukg5YDdu6=zNl<1%M zi4>e<72~e}1t5WSyUg{}$Sz%!Oh{FkM4hdwHr|MB*uQ01+;cupwxMb3H{Z0aHv~J` zCZgFIuk-uB35NnQ0$ZJKZ7nVYrK#v@+nk5sVwN?cCRtk*9-JYI@opBEdNGN+tEf&` z_*;3FpLU)z7(XY4@Ds#Tp+RfEW5YIvkyNfhpeTuB$?V{{v5b*(gK0V_>rs83Uj@l| z#R>S8pwLn5Felf^n!>+%nkyYADO+1jq5(HRHU|g9iu$||oI|=rUW}NGiS*`uBuuM| zBJ1zlj1A&!GRK0PC}vbN zQ)$ikxr~&`1!Fp4%yM3@03vBX$N+b8;b=mC*1b<8lT8EM&q)Gd!pTHZ`2JbL3`ynU zInyDO^;ja7(o!nns(!McgJ)foq@&gS65^IaDE{TP7u48LenmT$13aC`L0d!d3tVhw zB$K;TV9D&Ei(xEbN{&`v$j|2D`L2X=lk{nSk$MDwOX(-&a6AiH6r?#tCFN*!%Y+=_ zyxAo5!hO9l(X9sBQfY{a>U>gqPI@p5B$aSX(PLSsT&Ta@{i?_rGwCd8oqMnZBTLz6zT;wlOUmS3?P6#uECg2ZvTXz9T00P zFutHQHW!sxSq?K<93x-}dAF3yN{N2<8TKLe`OobV$|<0kM_djO=O|F+mE?n3AA1E{ zMpHo=6@2ww=HTASgVJm0W@&nlInws+kn!3#h@S=U?{|{YYoN2w|4RkK-W(AzuNw;0 zz8=SSZ_n*ZA5GU(51gzV<4BqC%MQ4m@I%NH5?JT0gg<^}d^&k9Y4tA{&(1o^ll{#A z%iOE`@PjTyASJEbDpT5}5=Z8S<*?rs+uu^)-Mw z#01$%Z3WpOZLJwgm+*N4(J`C4aj$WP`NJs>1xR?r{FJQ1?=;S@05Z!`#X@5-L&7}T zoKYb?1b~i(u&Dxqtn30vEk~%0E%lprsjz9A&QB>zN<{>K#2F6`ZQr3ym1QPt#jvpb zl5-vYtTTO?V?Qz#QZhQ@VS_W+w52?eQ<)J`CL>~BzLLb1(93eC@J?C`?Zq92{Znn2HC81EQ4`Ts~i>-F}CH9$w5*}8B`jMd*42^>^Y|BsQ z&PSO2))N&l>;ReT(ji~z3T?e-`4!b*d+p$cX}SLYa?*L+M#1)C{|~Vnj6lP1NunAx z@IlO}N)J`b5V4eMEMKTQlfkgMMRB+TgN+(HFgAa=S?hl}*B<(=FMEPI27ik`*Rz$c=gB7 zuO2thkS?-jiB@ucfmWtGlk>>RjdheL9q9?sEu|AlR=C_-a9=O?=Hvo(Mmaww1~d|2 zTr6+z$k`n$rU`svc?~puG0@8Y-GPg_Tqr_r9+#wh2Mt!6A97xAcK@yMy=3r)L z>NeL5xcIG=wCWYr{pTB6f9bs1!+#iK;KH5fpt%GD2YB;NLGzYJFgCe!#$o8{5C>^j zY$1VnB%)}$sI>xoZpux6ht^~=9r zIcgy(M>N1f6kj5pL;>U)n!S~Tgt?O(&z2S+@b$#%|ByvjY%QQiI^|g1kG*V?PB_Pi z66A{{i`vKsKx2wa1XYETjhXkk6;CAkh;xTCSyA56l%YXWJADzNwf8lw47~2AHb?i8 zSZLu0DiA~TzxiMOHn?T9(<_~=OzF7)@4C)!6rlbMfGf^p8k1{;WL|`q!n&yj=debdO9hXm81#ECJ&$H01-@ zZ)+CAOtE5Z7^;1RzeD}hu+cSX=WLOji)p$zLevX#y#9nB} z5}}oLM9Su%WfE8Cq`mZ3bR&Q+1>FS-CI zmyEKZmDcmSEX*?jSOiT88L)QM4N9&ENZtqvYWX?<2Ko!1FSYN2?@k2#TUheEGrZO+ zhZkC$`!3gr<=-`LL+0_DzQoLMjDhefUFF$GCo605#?SbPr5Sgv>n=~ZQ^;@%T3j`J8R3ESXoNhRcU(q`c*yqixraNWyS_d@GiEzx; zDMw%|>1VJTw#Ernfl>~GS>L~5)iFb5>3n@ne~s8F(A}}<@p_YhS1W6V{%2_vG73n1 zHYL$TX*8i~vQ(I=*+1_+8TEq48S+;19gpVBW^=jusx%q43olPYL@K4ijx*FEoMcXs zutOmWE8FAHe#H?-!*~aTpUGC)G~exp7fz~r^kbMy_h1jhB_z!gXYidud3V=@7#mi{p38E9Y3 zaV8A1*k1!Hqms<%g3|UiBFza`>#)Bugczxmfi;*EhE zR~*~&r6uyyF_`CICrgh6%ssak_#12GIZwCxTb?U6A?8XW%&cZEW4>XrANxaC$2MMV znJHW6S=2Hu)@?jPsbM-(&(N}Rm$pV~D`N>;YAjvl;G&G{ni>&}DkyNA4@r%q`=KI0 z(`}#@$leVb-f@YAzq6`h$tTU_C$W)q-Ax>Okg($LU*p4VG>@ML`8u`jDusTV)9U1_ zWjd^5mH&!z1e#`a$o-6D{w_^M`Gg2bA*@&OJM|hx*~*M0zIgth1&Kn-N5)vk59Y0o z@Afmf=8lDU(FJiz)7=h)%PhAucdCX_jFR7X0i+$j_)}+LkO!*yC}{>4WhYjliwY5; zKR$qM-YJ@q7rLMmzv%iM9pq8J=%OO2FCjY?F8Bb|jt<71Vn@W8|3u1<*U2l2n5!_? zG(i%^-gMPhoU~naaMfyfC^>?6z!-=dx;LF%8s(I%8@euIkD~YcqEqHaOyBporRMqq z`niY<+M7-Xxrng59OWYB(?3pia&6#u2N_5sZ0IWTL@;UZfoHah0ejEiFR=DK@;DR? z84lp3c!<=7AHeM7ZSuC=sV1l+e8OxKg&ftV6(t+Q*R2>A!V>%&lLaIeFRJX}Sq^vnd^rBneO!mI?@WydTcW||)JU^UKCO=z_`9;@-pEnus_>ewvgr03s znjo&vhJw=v+hFKDK7y=C6Hr`cKh{EDWRh&r&NOFKJKu?Dm@WsK#`D9eztheQ()!Cs z9ESlDBn!wS3T&=x;WNo=j8^Q|F*cn9^4H&3mqvw#FOt}cnh@E>Or?sNtWDBgg^NOr z_JvJqrUp%V;)X&f@CuTedN0Qdqebaj=uJeg$7Kdh#Nv*WMI7BhhF>j49ns9PTm=H- zuyNATQxCXJ93}^MJL87@VTu_|z44=kfueN9Fh#a8nZf_G4r%7O;@1CHY}TO+@r-Uj z+J4PR!=GG86D{eKdL}(MBE6O|>;o;>GQHLr>En$?oT`#q%|i=07TwD{yr32>*tM8U znq)&){bAp3rYtP5z(&qUYY+UE70l)Nj5Ii$XPWtp)`&AwYCL}=tmn8PD7%)#p5BBw zGis_aYL}DdIcj860W7&A>a zSG17%T5945k~5ln^r%bet?B2um`u4x||=zybV_nb?Wn6 zb3(dm{C}P+@JM&i1NX*E%Ud5x9o`n29jb!jMrmVAm`Z6JWt|DNXwwmtt|#UHTQ*L2 z4KKxKzqZls^BRw#B8WC)>SA|XBDnf&z8|yO%AulWv0tB!DpDs<%QGG z1gaWqEDZd%qIUZ5|AzwKoNRH#UKyp+cd_ahNC>`=1nYN?GDzLSDB=45Z~mYC+ON^Z zZ2BQ=*Vzv+rYVA{uVK++PydA@aA`Gb6EAFxZd+A9#ISZcXH3E12;i9MiUT#@pY|IL z4~KjwBnVZ*krYqkRK5$3MrBhkzS)B(Mr{m|wahWsrp&PX#+60O5AHrH%>DPVcW2$B z|D|Y#K?%Dd3yR>z9?SRi?!d1vDn2uj)nmhU zrlfEs4q_nU4DeNU+-$)i27K$WH?Uo^AqD|X=epIZ@96t>zl z_VSP(&oH~FO&x>}c-utu_q6g7r6&JuQ?GZf8|{rQd+Fx}o-G1GtSl25F)1VY4(svsb1x665oyWU0 zd-F=BL1U;DTz&nyM(139(r1Ka+{Qb`p$~oJ_&!xkiMQ`&rpkyF<{3DM3?-qqNg#c@ zamrEP#;cCa2=^LPSzLwB4t1-r;`}g4IUa9Dh!{ zfe2Y;Gw}n+2e?OM%GwO=DF|tj=Kfe6OA=*Gtd+MAZ_(>=n=A_?IE`Wxt37l#h{=X0ZG&XtRnxd_>RstLyJmmw9{lx9#DGJ4)bCcQN%Df#jh zlZ;H}%Cd`VKGl+_@}O0w#RD%$6aJ63KQ+pTcFEpwCna(&ymyp+Om(K5i*-mX-N- z*sgNHIvDGbt9;D%Gh~&zn#9lzC8dwqV!1d=hYwLN%5hhIUyoe}afw8uwd-B>a1|O+ zx069U`J)MPg9bAB*slR6MFFrqncU}{IzI4&&WBF2>P@y!oQ5f;tRGkXCQJ;JeCt^I z>lF_Yc#25|H~*N7$M>Xd)Z$3o+joYtS{w1igk#)Jg|Z4fN~A}}6ynB~{>-;>_64-e zZ!e+Wh?Q>S6!peZ5Y4l5<^JH=fl081t5uN-CC-Kok9(ukxs4JD; zC3jPN(@0tq=Y9=3UXfWbB%-fZf8Y#jiWKy)SE&5-DO5LrlUY#G{)5@pDuW7js~+LT z{G9%E6(q%xBf!LVcZ3@-OulcC`dLQ%qhU&6+6P*RR9X>|3y`BzB|HYHt&6h zuiL}^WM3({$co77YRW2V&7`IoZN|iiXHsx2)$1+XlL(r@mdmc1a-|n(ukS=d2Jh=i zh2A{k!u`gZ+|JwO_IRk*#^aw8oi#>V7BkJgH*%<_*W&q7kIRI}pJL~;oyEl({ zlRt;Wt+*@^eHonGwDk(tx7k|+UEwNgDWpV91~0xp58T?$QwZv{a8el4T`a%x+ktsV z11v9Odikfkx@OA)>%ifZz=vJFh#bA8S%P^TPj?Ly=0|#>d-AvO+{lFC3e^&_jkRUBei~>=Q_`OJo=5; z$?B)$66i2`<PfaNAbToVQ?v<4rXBHrowKn>Gu?#i7GW)>EBqM%r!h4vN+%UiMx1Tb)U0yKk9DjfvS5XLW zv>?)lXR)x!Axzqj8!V2Z%uLPS+3nqOUpV9==1x_`zGu!S!@R1 z6stf#QWeG2|J`U&u*6Zk7l93C($94M8+ox+4%`U|&i!_r3SdKH($gTJ$AI_@}e2_#VNbQmToXoDwBNa}d zE0-MD{UGVje4d7hWC++WFKI~xf2zd;#B&)fPT5w5YS%aVk>OupOSFoyTy)OzG2dY? zN$c)?wjjzqT~PSwi~CSP`V{lw(DfAc?floo1>x^r5xJPa6q2xoer(*8WhE;ll(-xA ztosyW*~M$kEwb<)Bed1D{9&GHVEWHyNVf%GgW)h_ROa6;3Tnd80F>HwN2Z8*pZU6- z_-5hi`}pBLtbGhS>K6uaxx}5!k6~N1@htu;GWZkkR`@3*7qB2LwN%WtZ-UqiQ*q^6 zm#^(JfvaBsm~z|S*~;Z<`-JYTw+Z{a8~pfN;)=IW35LGux8`G)~#>&wc;rdPfJ zqUw^qtpRp9fuVSYtYZh{0d2W|D^o23(qp4GWvN4K;PdzoGAJff3tScc7F}N6w z7RH09;%qKK7A^(<3?hf%sK6{=SEBrn%r@zq0C&={X3mvd-2xQv5W}4Mh0ECHV_f=C zTVcO<3pv#vnDmw}K(k6KzdEBJ+FwL;B_3kN*)U%;&>>VbgE+(nVMXmM`$IDFGts$Y zentqc@wowhS)Z2qgvQ_=xDC@eb8kq8!UNQ$5teZwp{J;KIE(BN{^6m6bO4zDd&Nv^ zaWkxXxiOd4r1!}72J>%5dJ|1h!V_S~%WdW*0QL?dHkDAf2>)u){*Bx=1v-_G zw8*JhD2V3g0JfieXVH;2Yx-p)VSZWMN26b{qRnyV5T$I1LW2@;Duk2hibzM`3)WcVW zD^%Lft<7QQtk7Nur!C%=c#(A@)*SQ&*ih!mF63G+-i$rL1O#r zbwzN&fDQZ$$3S7rqhIq2Iaqhm_Wjs0!KIz?+Nz^0dT}ehn}o zGr=Kp&Zb@ad8FH>Wq|#10FaJ=+;h@o|40TGsRfur^Ot)t8$+YBtUM}?nu-pV9l=)3 zTnqkXpZ;(WWxK7~@gs=1Ny!{x)qnqO!-`K1r@IsAK7E6{93QDfLI->3=<L$*^e-P8kJl)%<$D; zS*adyT{2{DtTH+lmyvk{amu?>jbMq|mV1YCMg}e@v;>1Y4KJ{4K^x99OiKKXD=sq_}||F z$O4WVOnSJBXb~l7lVP8SkF=rXvIXTKT%;0sigUfe ztmM#F>ABlQBpPTiIImYI;_7vD?-ZHi8fWiRGC*z?n&{R=2_&+oK3c=K!Tl1r%b*UM zETc9x6$;Q66u-Xr1Zx9KS%HPFGDjxgayZq^CpoJ$qiGQH9N&JN; z&(5o8)iPpX^=>h(iK0U=gmjx*cdM-G1V6ZTdK7lxO;!sJdWU|{Lm(Qo3C}v`aQ##o z!3|AgB>wp`3j!SD7q9QKH09qkg{nBOFm@W&!S8&dNlyudC|h`!ShAii1s}l~I=N_9N1l|CBvT z+4F@x6AQ4xd2NhXNr^XSRuH=~rcltJ51Q%BESCl9rm+Tcuyh;nf@L@;#?mapZAWkW z%-k%Yzyh3fCT$q}VFX>{rp@W4gBuBJ$Oj(Xs9VM@94) z82!`8N%f{12MsZ@MVpK~a(^s(|4xpC8IoBPk?20p{Q@7G;gB#HWAS8IAIwX$2BtYr zQbUV#vfhr-Oe<7dZj_lL-`!In>ptK8p90A5!{Y)TH!%v4N=qnPg?_#W}fnAxB zVc$&W`Lqs-q^Cm5W(0s?(9#@7Cnwo#oImv`e0<@4_g8z2`c>AZ!qR9s)t%p|FGquQD(RCKkcOBjI{~US<0N zY$NoUi$UHy9A{a;w&W==F)p1Xj769`eoGO`p+S#Q#)z0 zxZ2F73MPwGP+9QMBd4hX30UdF9Ca?}kCJYZ> zV^pnTh&YxCC?B~5pb{8UuEJI*ub?Cz$Doq0YY0+fA2$5T;8;G+QxWW(~PgAaq^C}#0+dztRV zjv^CK{TLsqG*ScAVBW#0t$s8T(3S~rXP~r+Xb-cn5;&sf{sF|tSHVYuwNE5>oB={D zo|%WvCJhY#Q~>SAZjgYYH3na45dJ5d=3RwU>u!54;`u~!DniME3^{|czG69R^XBnXa zf%pX3B2ef=4)w@H4w@kcguX09Pxv7Bh8Hc&Ct)H`1p^|=ILOOofmb@p1~~edKFJbX zqGzH9&SNghIzKwcmtc{V21db}m>24G2SjL*?nmpkJ8 zFVVjk$7nuVM5teWJ4YDl$K@>&6)@Z)F-CZc{9j^8G)y$s=hisnR3&+2UG^{#@+(s% z%?#Jp{#Dsh2vGc9I!%IhsL^7OwHMOD%ovWp#Q#bU7HL{`652>uD zsn)wTr4?pb`3oldzNdzSiJ|xjI5=ierzLkdN}8(6&z-5FMQ=#*1vZn_k$7B>7^%-+ zEy*DTWOMkWlaya|JAp(!C8OxK;lSc z1ApM?j(e|InEy}nqEL9FZIu_1&$O@i^Rqa!$`B%Ht^6a@>T2EBz(c)Dl2}_T( zG+hVmCas(P!ALk9Vf;6!q*%hIF=VVT=)e8K8inVYFC+~u3WDSt^ zl^Wn-U7ap8#G&0*9a|ZKNmiL6N0mm0WQ5IG8Fps@Y6h+)zFiNC!%^Xhu2f&%7b$Z* ze{e~W`22vl>Lx0+UJ$QD5rTu-=U$zYk}|Mxip^L3U|`-GUsS@%NZc-!HrIKN`*eV+jCfA!3t}i1|fga2jR4%5@H%{X0W-zu6oq?b&NeTw%`r)fh80z!N#eEv(<=P^ znJ1EXrZv@jCh%b32wk2c=LdqCaE1g0@ic2Hm>J-I%xI)jfy2<3#E)x^=7cuVgx<1- z=w!{at@B`3Xa-G(TWA7jJm&P@snLTEVMZP|Yr>(43ZP)?ya*FKH99Z^eo1_8gifnG zw~`hNff;&lI&-~HVoim*>|GPCkf0C|SlHh#RiwySjq+RJw96Dvl^&dmzSN69L{}uw z$%0f9Uh-Q38i}D0uOJ_6ZREUkGcaxE!;CskI8 zlsb<53-~x!UR-6&*eh^Ra~cC$(N+>rOt$XiZYsL6HGoc5A^rE4X+Y=#;>5L%@7{XQ z&dRT-y-IL2dC;rLQhqbddjt$*sdJ7$=u15(HN|;EBGMkFu>vx;(o`5lt#Y0P6P+o8 zWJaQg%pv_;b8u+F5`r}zlfW}H-3Pp|YOg&hdsbP_xt{ne{WP5Tz+Xw!#A!5OYtxz` zbqeq)O1uJeWEFKO2w9`kCR;V%G*R!jLifD)KPdp1`9unp8|K-9^j?}J_UU=OO4Y=( zr7E+g6#rdm-*y*`P;iJ&fh#P)AiR=<8XMloD~xIV_1iFW@#q= zoTaKJ^2;_DL#?{y?cewSy)iRPE~p~jMktCs&SHI?wm-l2%7vT~F4BZ9nuzLP)l0{Z z!>{D7;gtIJn*22(U41$KiN{Mq5}gGKJ`XwGT&s$?XvfSq*+D)*D~DZ%cY`fFaZa*k z^E4uRf02lzbF3*^-_H7mlIV`%WV`JvWj-q+x_yy!P zV6%tUZ&Ck#c11U)&Bhsj&2IVpCiW4joli@-B*d4`;N4%%dZ_;!S-!f=1y{6=y8!B?((y0})Uc;XkavxfoOpIw-zqHzkk?8b%B(#Kyx8M+Zuf zNrFlwLzHmUWOKISq{Y=zqTq0gHz0yoB-jM`JyNB`tZv+dIP%9N-=NEvTclcV1A@7X zCA{cSG2Xp2Rqx7rdVJl#dHD=PGD0QgO9DfCcm>T;GAxxXI5 zZe-blmX^^(s#vLRND(q5>1o3Onkdil9eYy6Y0xpBPc1LLRP+BtS>p7u}D^) zh1MvdB5dvwYdPNn-Q$7%97z^{q+k7o;egN$X%@v9{W65ZH8iBnWaA?ibG`yS1VWnm4Z&{ zXP%Wv+BJsxmuXjR<^8oik$yqLnldQ$*oFXGt}%HfXvxwfnYVzaOl*w){`J_+F}Pi5 zF3xf5iP~5M9c%RAi&hM2mm`IgB*Pz*OP=cTZART~1!6_s z6~!i~lP)@`O+yjD6|&lly+zbz;@Tx$MbyrVPC;`rrF5ct@pUTE%GosKf1NDm{E9XB zeZ%5@FcYLB!#cTjWNku=L=#vnh6f+=Ff|P&%~JeM0iu-QojekLp{Bzq<>h@j_=4BH z!(u6o#Y)m?BLY`XKb2<-O7?I{br=@pW`sovS6*| z?3CxgN{hj#o>`JUU`(K#8IUb;F=+-c&iTu0&=%qsF-d{KlT;8CrH(s~!k}>~HzEZ| zme!9nj1t9iB`yZM+mH_Jb#?<@6-@a9K+4*@@qNEOt%T;V-CI(PT8p;mPgF!mWj=`d`YU_?BUp}ZlLhcI07O2mBS zE=AhHZJ*!aH#8v|{_g)>NJM&WJnc&SKWm6;hrr~I1{ z70b*;gW)F9(39FnnhJ6^V6%A=(VvyGr}EEkpYu~AE?su$%{&y}Q*U74vR$3GG2CA5 z(Y4+EHF9)5v77fyHaOe(Z6wamc$?7s-gf)hUSWyw$?9S`dcCrH?3`&bkolH#9WX_^ z)^wNGtKS3`vsX({6SS^JGroBF$0XC;$?c~8Z@~Kt7A`GO2a&hB_$(`;Y{WA9T&D!G@P;~;7> z*yZz!*hjVW-&~ikZRFOjGE#EU*kQka*Zw?E_@=8RG#~HjNIh@IlFFwfNcSW*jZ$l! z_q2ar(J*69h?{%x8uBY5QF^d{cVwRJC0WyoHRQ;~RLhF)q~}S#q7V#mX)pa($oRKk zPvuM&-)ERmJk3v_`Lbc5{J?IlWrgmHjmnOCZ#9eSgI(L0ne$1xpdg3fAFvxktlE>I znQFmxef5Ehf?{)k+rq+g)zC4AgwvrEf$CRbTjWhv~VSguNd{_IYG` zreMF-Ku=l6Cu25;{G*&u>Z~{u#!dHlL*pMTvV^kmP;x9GMRI~uPKW&8r;ycaZc=%^ z-@e>M)$~M5p5zpNpRjNqj7xx_N7`e7P8I&>@aLuX$< zSDd(2Y}?W7(0ySbJfk<f*KVt+mC>6au|It_`l^sJ?JQ zoAfr{ny#Z@--7V&Y)!siteM}Bm8|G1xsoVFD`g3ndRhs9dAxPjA8W0+Fn~8q&joLf zMt;H2)&|#Dfzhbhh3XMz#ntFneqGNV4~?d+_-3~~+g7^X%0frjp6S{0x1}bL?Ym{P zt1JQyA(ew4_m|893Y9-zH2H~w$)El8Mk>^%t8buAF|ImtkJc5Lw@K&ZD{Y>(+AT%i zpSf4q08(>V@5qOR#1{m|_s9-hKcvHz^eYuEt~p~pSS`J5x)Jt= zdk40n%{~NB;!)JR`!G?tg6L@$)k=>S<*mXqYqfjA@wYxK6J&&iI3LS-9WCMKWFVN; z67Gs8sUOqqy77~3y}8u7e~>687uNvweOFrOP2z4Yd-%oC_51V(DIuBH51#|CaYWqA zC|_u_qzlBFuBl!Yhz>d>P0XlY@RtrqzCL$wBzxiUAOc1!*rBRCW=w-Xe_-!DB_6R}|RT;i6YaQp^ z*>K1&`KUd?XbEmRsiS8wkIAYIhc%^`dK%PVRgdEi7n|qSGtTDL`jTT>ABm@6Q;@=S zzvM_djPho)COtQl->!#07?@Ezxa|`4AE^%w#B{t>(E2r{KGfRz2u$$sUq)>l+H-OA znhLsLJ9MFV=ciPzmYQZyiKN<;C02}f(md4=#pf!VACO#&B;?$>2#=b6%9?Ta(k!Ow zSPDJYDb`Ww9pB5vH=z^4lIIzqG-6_P==(1o+WFOd#OB*UX z2$5y76nz3!4ik5z=w;BXDA0HijW@|WwJbWfEK?Z-vEPI&$@|as+`Bqcul-^WdAcq_ zi{V&2KQ0PHfwQg}AqR05x)tDn##C&rr`UVy;@S(bJi%87FA|&=diz>~DDswZ6fKgv zPVaNKDe~4_I~Pkx*UpOMDf4s{7OAa^d(_$Wti1#g8>Vtq7iGu}oy?oZ=QKbQ^jRPG zD0fN=7wl@$J8YM=<7Q2c)4Z3}(NbCXC#IT?-qSdRbtz0GSZP)ore7@txoBKb8KZby z%gitzkx*#j>74=s2$3&{uJYFIOvO3c-k$$whJ{-4Lr_Ws>BhQuQZ1`Z>+iU*s}n2G_!3Z;_fR<&>oyGN$FN~2oIJnftOa&9Ln3ns^K$>nt1TOai8X*VFyzWL{ znW|6YBfYe{0v@-j1>bC%*BxFfmsaf8nTJEWJ+!!Oc482> zA-$Kq+vE%?x4vf}b!8(MDYOlpY2Z>lJDxk7N!fZ*Y;>0^;y&3fY8l0nRiJN>@P(z^ z3~s%VRUp*XI?*=M9=eI)bd$Ron-J(iNW|qr$IaIf<&4N&(IZkk3~~Swv@@1X2$>p0 zfnl*$6-Mf`AH!Dw-_^x?62CsB=SCDKjOH0;16-CkpxgMcQ**%PoG?-A;0BP5eF>VTUn7rDPdwSF5^CP7Asp!+|%d)a2zA2=AOt9Oxn(1Jj zHEH#bEx|}eB*!JRuN*%fDQ!p)_{nMs$&_(2v_fqGxx#U&15m1`4UE7w_J~1-DaEgD1)%2aD)ZirdM7)C z8udBW0vgXKe5jUvr!+MLwz?l7iE!l~Q5%Q|a6Y1PVtl3JnwG3c7+g%JrsV$M`0DO; zc%u2)ZhYC26#%ce9|WvnR-sU~o#H<1JNGZ*0?8AX`h_d0uZk6aJOqTDwV z@xW*5;tbd4%|5F$Bz#b2OY>kabvJe6k(Sj4;L>4*w$bWWfD7Z$ZzGLbGF;QoiRRgn zW5X^APNdeyW^bD~)WK-ScufpE(}q$GHJBj_ddvtg@d|VU`C)u&-{+dFnu=tAwHUtv zynZ|Mwq$ORvrRzqNG1~;_7rUhanzn!nrd<_K=5^#6N^^jfJ*0@EApzOghn>X0dB~f zID*)i_)_^qvBt-;b<0jKY;R9vo0l&q`Z5l$zp*iiNx#%cwlmn_5RD@e#hAT_*+3q% zqHfpcpRsSAp?zw?t6?2!YM(P{!tI*jmr#(+gw7Gy-i4Pgxo)4GDAcT0fPpwM{ynA} zuv^tJasQe1yYmsO*sHngMTsC1k!*Lb*HPNDhl936PP5KJeweq7cim-gC@|0j#hNYL zWE@wT)NnP(l$>5098IQq!dU`z%x4}Es<;(~vV;%sa18M|>THMLlFc_^!3>k@v z-c$sakI1fMDP@aC-fp5Qe*FfW++-fIQ<3iH#F6U7qTzB=!ZlJGG8!npl>rQ}X}^ zS*jIfWvk!cCJonr{pPPykqJWmIj3ZrdsuKamI6JZ)J>0ecCHYv4tW# z&JzjKQ(~fK2f&a7=4Zx9p90EY5f>2zop$XGl~Og^kM1^76K9~ezjm~SBU_+L1xS`PdpDf!!kTtQuU~+bV7z`at@0o!ZZ>_OM=Adh`i?9k+ z2m2PHd>;Jtt3T$Z!{bM~CN*XEIYA%K#sUADlo*j#FkykGY#yHn2CkL>lW644&*1@7{@PqNP0X5{rB=Zu!#cQmzfi#Va1 z3E@CO4~Kii#vD6vFZrtv@BK=dO+SkOd@k@S8=w2a!qfqlx(KYrNk28CSkXRKe^q}P zpL}@KV-Dacuz%1-A821`>EWkxbU!Ig!6aCLU$LCbNR((b=#UZY`QRk0dX3N=C4Krc zeK81anc^m^E%e&A>(ZAAA6lp_0tc#t{{0KVLo&kx+aF$tG~I9{HS*zKOrJta4romH z)F~#dXH%-7moQj|xiw?V4k`OS(vFr7 zbmnliaJ%5hRhn%X5h6Y`jI)bBCyFdJ0Y>$*%kULBk*0!s3CW^)lUX2P#Z%OfEjo1- zm|_uUY5BdO%DctUv=%KQwQDY2_@Mw2SK6P_8K!YrmkLE!dnq~s@hqW+!tpD2j364x z%3FrbVzPNRkr^J_x_d|$nme(%e%}(B87|1`iW=Mf(AG5A=SD!;J6P9r$ng?u8dX6y z_ZPJw8_keD!UJfB*_ zuh#B5f`W+5^dplunds+{JEn0hd=XEB-ew+weo4 zmgyzuNZBtqS$WPr;vFkwCnam%ZWL>jZ_N5(EZs_ns@W{vCLX_Vp~tB4%jxklO2Rua zD`Y7@!i7QyqJg|9YTW#C)Tpa}9=OgOqXa5sphaURkQHQFwU=b@a`(Pq zD~oG;bmCd*7+$?!^@dj9koq-ZYbOXlZb&bkyzj?6+OeI$Y<-Y7Wrwh4qc{u>o1KwV zVj5(f_0mv;M7f+9KPF^c9eDDmcsk*src^SVlRG37mx<$F~VaNI{W|X z*O+HbIlo+an%NJS2xn{`LeUOYo2WXbjig|u#FL&C(jaLlKZJ_zd4W3% zYj#QgmjARo*x_*znw`x)J)HLmhCqC&j-y&q7a_*1DU^k_9?&gYzipkYDG9X=+&VQ# zV~t7ng@w-`pQsAwLe3XjYYV25zCQJ>HoO|p#WFd#JxX8ytt6W(3^~o5Z;p|w#5kN} zvFlpEoKBB$(+NW|-J8ilv#oA|^h)Lo(1PPFYI$K()e4aa zxE&(OrW0mk61I=`{-kU*PI#U6n5!_2dZCdXTT)(%&X@vqJZ3#jqyqMW@ z4NCJ5-Ijh99gzm^e`%apxwSzVRrrLfOMa1Taf9TXqN-DMCJMJAFH*yJO63r(-=QZ*9tCZaW;#ooq37js=)fc8(?0uNfIVD%B{XY&#>VXs!Wj5^H zwUFhwl*R9`mJrNW7nuK3KgNlWFCWFwb3OebWR!fkW$wrsIn@VSuA(8k{hb?2D5 z8=Jyf;yB^ILHcRr_W+&$sjA%t{^cBxEzC z*r>83z}I*j-diFxh^U7~_5n?#r%0m>*(p--UPot^QQyY;t@}Jr@sHDVm#kj6N)YRt z(6z%1p#UKywd*}byiblF`5N)}a4KfiI;YizoS$fYq=6n%$f+wd&$HSFCBsG=%Ilw! z-RI%@KG7ue2Xm>deSic~MOrH6FyCuvp;H+EHYdWTa{+=n0 z+{aFzrig8m+t zx!`@NaIt~>b%Am7Ovdotu^0cTi!Jm+p7;BOSk(M1558sVe6kj**btGqDoDet85NpE z7XMCR6nX88TmIQm_UHFMCIkeO7JKVHDyMks-!(orzgy^+r>O$*WEno~g^^zmrFLO6 zJ^fXtu7A;g0a$R^yUBTi<*X}Bhvo1JIQgrZdFt4X2k*Pk5KD~!yUKa;*2fOONW?tn z%14HZj+6C?7pD_g!#0{s+5p2jOIHF;ybV_P6N9 z>edDu{VxyJex>ng_(TAf>PqqTZToLHD731*JO4^A9`fn@Nwr^!` z5ftW@zaE8iP8qW`Sp+PlMPA(jMd_y3er&&JeI7&=3@U%{a6E=^w47Fs;ocQLU4?YX z@i!D&6K3)V-FC09cl})IlKGA|A9z{z7=)Wv@m+XK@$8ydzAxiE)YE?}I{rhRG}n#TV{@GOv!yz8 z^ZP_)?FSHv`-}7s)$GgJAK4dcQ-jx&Z-VC*BCKhi{;XLxZHloG9!E`4R} zMj@8*r^lgwM2EgJ&c*@fG2mSA?B9Rzq|K0NsTt~M0&vqVE`yU8@8j{j0M;O_pV zYNX(_ogo^l!XYv{o){5t23JS3m3iz2qg ziwc7l-aA-Osp-YOwb@$pPJ=ZNWU!m#;H(#!x!cSa`!9LTNjO8Dk$L6Ujfm*GUsbQ1 zy^6F$mquVB!950VPDo30@hC1vU`+GW(eCh{zM%Rtnb4$aLHGTX&VCw%t8febA?S&9 zK^@yk=zh^TWzp`gxg49s!YD!24ImEYKtG}xk??l`zJ^%5|5pAQ&_B2(C<(~b2Fnyc zOuWOXE_{bS}RIE(Xy< zT(?MtUBMy=q1*oYnzxt>8l%}1V{slO^7^yxn_|oTWKdL2X4Gx6S)m_h&a4F~@9K{S zmV;tmD^L5}U*Ep8%PZs_JESmF+&5l-f4)Lk)I5^><@%Oy^rvq;qHc`7qSiB)%Aqc1 z)37Z<4(q|yifYZ=zU=-QNS?IE2k=^db1L05kGlUD@!6yP6iBDcl{AzGQo&8wszOH-okLfy;jj7yGC6#ZniX_nilG)9O-=)z1F|~ zFGWNWUFRb#P8cKzDe}2>H1xd^|f;%C-W9*KORc15o^49 zw|@|`>Zc|AOhmRvq`7D}qA|#1OOzX9OxLE?Fny9NR+$Tv7~~r_K6t~nN4qO;as$Q)F41f8HpAN{R@|Cb zC(<#ryB=gL-yI{#_=iDEHs`)LPhAhy0`gGrAYuVTuc`!P$%|VfkF-X?9f?amnS7Uf za!YV$;Jw_Ex@7C+F50EWInGH6Puig~`FNS}*p&dc3hdo0mK%ioxGge+eGF+w(q}W` zj?Ul7g;K=E5b!1<2au-(-JdH4DNuGq_XY$bg+i8sQ@lbk2qS$mWNIv7be)kBSi_-J z;BRp6V+%e|T_WuAOdpCQ7*0Ko_}wP320{y+-$2}>8pTAsVuU;8cli+ac9Nw$mXerq zh^(4^h%;>Sd}IrE<{mvg!gOcD0!?PuefPSus=v}o_q#f@dU&NEs68~jtzCI6Pq4tv z=$$mE@wGAKk-{FWptKu!@4QLygmr%onAtg31fh$UF__akOT=HSgX-hj*M{_zw(a*O z)E))+2J4QbbS$gs@!A{nER=4OSM`EFd@Xc*mzd4~MXGt6-{np1dEK1M&sRGJWR|{o zevySBDTHVzp*bkkWnqQBNTv%FxBHc3Sw*h=;epGumShcKe#;?}dpZt83Mf2yRwfg- z6b4P1zvW$#Yz{~_QVCrr>eJ7G5;ppah#ST1TgwXs8YtH?At^R-_AKGzaya%ZrDJpW zz%j-bIx!EA)#V%&q}9vnGb4l?qVNpBR5{4L7IW1lkFL^ikB zCP5EJ3x?}iC#UCSU(apdV!PJ?}LDf;K`%-idV>!N|%?;^xvtNhW&*4iU@K8nEu z_|4S{-G;EvSCUAQ1CUY5alq3nF3Xm{6bO=UuGzum#m$X81AaFM=6fXxT8Zy*_~Hwm-;-#2-poKv77zJn)_ABoI3D4TZ8e$z03t#245S(gQ$9x*-- zgd5}y0?`f$g1RnD8~LE z1%=c96l(^Nl-9mC-b7NMO!~fo|84_N*U5m<@Ux>{D*+4lEvQHWLdnZ7<1P^XQg6~d zaLNznkiYv-@QW+a76(apE867~`i>s_aFGBwL*eLslFp>R=dIrLn2oygjgC(>W^VieJ|hYIOa7Cc5xSoHG=~0j#`kY4 z`hFiWp@SO%@y54B#Orz>RFz-TF`zfns__oV)nNu=pF?RL|4WKTy7q@cbns1ALgwRR z+3CmSWYGIjSKN1tc4U#Gu1;2eCmLZU#D*?@mEU%9vTKaMpXMUC%z2u7yHudgei zi4uvJIQ?w`EbPER5onRCg)bk!wH!CIF66=T7%WU2o)3Zm(m@ya$nX%cz`?E#Ew{S| z&FXQ>3k6yuJ?_Ds@xR48v(+A}XM@TY{y?h1VBKMZL|84+G2;~LM5fpP1H_#~#DcRW zUMeMDHVl4JW`U{aNS}X7farz8J#%y2rNuYPSwG$eM5wsfGRBIuPdV5z@`eO5#r{*= zLrrsjXsEUWQL6B9ZA!c=%EUB?@#8QQOQ1Q6g57eW^>i5uX%B9_34}eC^D+cKb)X`C zhX(E`-D?+%0t}I4TLjp+`c^ZEpaMpX3qDbFYZwj?0>qz%Y+xD%T0sJb$+^k#UFe}@ zA^S*4M7J_76I*Tt`AMtz0s~&{$YoW~JqI$2v`X{GVQEhP@en(4kJLdkqQ9NXGh4b{EE(~V3@QEdnW z!X#Sb=Ajlm1&U`)hQvdfrzxJnj=`nq5UR!%ny4aWit~xVX7-0R#VWXvCZdh_I$MjQ zh@xjYb|^pBPc#xB1c7X8?$^7yfT0pdKV1(07UxvIXC@5?Wh7U7sBF=+4KoN2HTydy zV&X=eFJKd5f6^HnrqqHT^%(++n~p$n3mggc-UZfp}tt@sT6* z^+_7_#znK2&rINS#tph*0WK+4grA2YQJi!VcM09vcoV*)cP~0B$P)T=8zHLR`tDaJ z!UZPgN1({zo+YvNn}edP z4X8y5=GK^x{2x9lf6|8FBdsxqr2JH)ieo)3-p>pawc^VF#NEtNRk4rt`xn`!@d4rS ztQ8_L2rzW(&w{z45b3@*P@)b4J;k~Zok(mA-c7_;J-$${wmG?<49GpN5{MwQ^D5|> zij_g54PG=`;za?*v~PGGR#s&xqtsF<|L!*_2$fMfE6mAnZdQZENJSs%YTY+8uCsl1 z8UQq3Q2P1jc3O)hg&HGdaZ%1v{)<2fJCoC)GV@m>7QYLB?t(tCE~qbf(j$YY3rBDR zZ5TPnPB6#C*scktt^JH(iY+5fwYE3xdcSFge0DlKd0Eg*~h{CP~} zB|uz@;~kpK>HcYRYVWb+9&~g&1G(O`QB zpj@gY?0$mc=nS0eB~(XiVI?C6!lE%M8&7yOf%#^Ef?$poQFxGZr0Bi?d0Mi=6<#f% zDazuX&2904unhV02tt6xMHe}NRy>6;W1ti;5eu0w(5ewu^i%^F*#HXRmWp!?g>+bk z;UB22$z+wBL-!Gbtf!Uv;me}OVc_*wrE!Cjk%sm-9ThMduftK-;>(An{T-{B&3e6P zVm}zu2O^A6HSiA!REuGk#fkq^NNW`fIOukv-N@_9x;nAgg)d;4V~LQ>g|HufdS`GC z9QcvkVcf}z*o+&TT59!TAEgyL+!HX}ga@&&2}2n3bwxfj*gu|0A*l!tbQx^c{QNke zWi+x{AEhYzG~rycKW7D0B!gqx`J^KpY&%F0;C`~%5>y*Foz!sRWLN*@iNf@$05cyJ z$F!eGlJxga`VSPo#w4#Y2!n*_h8U*6A!Hb!3AqjeS!V!b*408={MuvD(y|KPqAA>; zT&r^}55N3^3<(6SoXg{wd3piB0Rqq#6hSLp;XevDO&+R|EAa}AY!~QgNam@hNYfKa^RJ&k)Za>`aQi&aDXQYILiu43Psfi zO&5-kV#o&BT?eUaRMU-&#craD)|h0W7uJTa-Bh7GEC!Lj$qIlQQ)e4o5>I5Gc0JCL;^Dv~j*Z}JD%39L8D8x+E5rYE>gg{tlZW6j00M$!WH0Q2ir&iej zf-|YlcZ8WYhW)v^v(q@$7`AcF-A#vI^B;Nag7YQ7KW65i5Vq3#w0}`xVIgVHiOF)j zGo_7@-b*p)%^(dBo_DdOd4wXW1TvxU5maynNDQgJ1-QIveuM~Dd^23?uy_Np#XEik zftu9ceqJRY8CFLbIjE@&*Zxu!`@m^b^LK8Fx!$pOO6mV@QL4UypE3&zl=9B z@Qn3yi(L?6!MHgx8e>=lu)cg#5r*}=)~n=wWpizPC1&BAnrj*;z!1w8de`$pQJUzo zf!i(uGbgX*Mh8ZR)U7yy1;|*dOHjBrw}e(7ioEV*&ND2oRoXxH4tL0Ds3UU-Jlv7R zh)Z*NJP!x|#EM;-aIVNajIv*5T%Ex{W{>cl9Y%b)wSL*%cfDc9%11!V`*s!4{0p^L z(d&ll?mk?M9}%*dRH`fh$V5o`TK^9O6m3r|@JSeS@6Wbc7`W$yZR1R!f*O27dcABu znIFIQ^q}Ait~+>yDyRWP5s^~^xA8+ul}*iS13w5b#e^-zYE4s@>7y~({T~1z1PSYp zr1eaUdVv5%U)#|E^=|PcMUd@ zHF;Oe1&8$ihWlUu@HZrOQxB=-eobqhkz|%hP}^vcz*P<~Lmhb=ogw|3)j6FL&M8C% z07dgIpw+eJ?I9Y8a^%0==1Cq$26W&b?~5q>DZb2N4)@!wdb7JwuWc}wZiEYK_4t_2 zR9Z8qL?z9uHSdHQ5&OX1l z;s=i0hs;6E8RPd_imjS=t`gh;IT=3Y+z`c#z9?J{+ zUgfC&D0F@P6B%m1jz}`16%k6DF`UDc3=+!s%qmi&1yf8(-ihJA1Y{P1N!Bi03kGD3 zEy;TslsNm9ze(283i{+$dqwi#gjtrS;tUz@S>7WtgxMBfqD>;sCHes~o}&L(7KSQk zP+S1|L%{{j3Um(aH|$;7TDjQ=RgQQhJhUdFode1tk_FuTJ5kV>Ji!NeXqCuZOW3T@)xM+mtJdA~~E zoAJ<`65>LK*)T@EUcTv1Eb>zYzT5{M+-ztd_ zug7e$FlWjGx^6U^9U9_o=TwXq#jEIjBz7*Lx$>6?#J4WzDEm0rLTaRV5AY9Y6!pzp zyJNW)LC-GR5MyFx#&JvvvMu|p6~=A&oI{AkVaBK<_gzQcYXUD-8(9cuCFi@XH>|33 zh75_bCaym#ushyxV%rrg;2W&K zb)Im?_6hfvm|*@I7(VffL2yE`Uw!Z53*WLeRRYsx79#n|>!|#LcGdZaD zK2>R6U?{V~5`Ik)?6oh%Xgl6_&zHC#Za-iy5Zq)DG>jR14t_U8xc&L43x_F26E{22 zjDlSvndq*YkDxfA-0$*3I%l?o&Jeza!YI+c3OF_i8BR`_zR-}ItT6E@Zj{Pzn8Z#O z!MtihJ&qbb%u>ua9w3!7k{$>}Hd+SDvB^{d91Qi_UFM<{Mm~0d#fOH|{a~f*RXl6u zHO#cN^r=XL5G9}63>zoY&Kc{_#~3yMhZnd1d7*hyouIc?qqeI381W%se=rKitemj% z(*e90urSs-B^NSDV;D$ARD!Ulg*G8xN4RRj{T;uujJ7oO{rmp~BQXo=p@UN_y0`wS zj!lAQyBc7+Z9Fxt>6C;7h~qDn`!sJSXU}I;8VLUtYOa@YsL4JqXtwwWHx47j7Zk+> zr<|Pg(>Aa{lH-&tz6s(mB^-Kux;gD8S|b%jK~2IC@iQeR6?8A8Xhc9ehSo1(WtmcJ z7(nJ?PK|_?a~w1oGxyAR@CWj^BrtV4njH9Gwkc-)@y85PL?-FiT==9`yMB8FkSccx z9eV#k^0EmFG}oX~Uaw6&%fF$KnK|U)UfgB;UF>uUip1HDrcFvp0cAxeBP>4A6@$!a z53eqvr>Dv;7suZ4p41+0cM|cLwei!leexp>;Az_O7>2mIsYX7NQ3lSB!4zQT??V7H z4WdyCrVmBw6yZ}$SL=P~?W$!|EnTl9KIwoLGPxjjUT-!9r8URwc+FffdSbu@iuE4nY1h!Nh=>VKX+&h#7T9|$I+)tQBWo;8 zWCk%IW{7x3hh*QG%;_eWP%ov%=hVpT44c%|QhA+;x_M0Z+Ge|`SW`ackNwC;<+Tljr7$^}HjRPJwhYr#4#k z5G#Eh3|c0XQwyXp?gPI5NA+2K$WBZU_xtdVVq-^vS8K=`AbNq z96ndojnFK8t{OmuY2DB$x7w%l!M73pt`>#-2Tq3W+vn|9@JH%X&X`Kh^D zp;(E!gZ#vNKVCk8j1%g9NRc-lz6%I=0cLamgy_b zOyRW$-5jJ2q!lTqF)ITl3r&FL-_3@D5Q_`Xa|4-`N0&wz!;>WVBH6Pn>x9Nj&6YS& z1MA@gA`a0wYqx6i>2}rI_0JAv@oe{*Y5v-2PqVU8RKD(F-7pp zy8g^Uah~2N-x*%w)D{1Nn$NNsg#z8+Sk~h5M*5p&ef zEwyR-_2kJ)D10A;w(g$!uItODd2X>=IkA`Abv>@^?_x{coQv1ynVT6|jMLb#_3fz( zaVM5|4ip3!f9fLHl^t_?u~lJtbs&H7g_00bIAM&E}|o z8W7SS+wQ{>Eo5q#rtebQ`sYAMHsjl1&$zm~h;>h)whA}_>}oRQIGkYoqI(~%@2~Hy6O9i&>-Lx&EV_A*BZDt0U7kfP0G_{~gvs@P0ZNSLrFS6DDz~>Cn~$w9DeqJgG;C&g=%56*RNPaa>`t>`E*8jI zi+GXNzqGr*+w4k|$XR-pzzkO<#k%mtzx;&w|4@MIdWoE?nEZl+S-973C+Gl4#*J*`phc@dcu!~binDNRV>!*UV zTNQYki7O!(V&9ZoK-m>V93jmv9Z3Tf;mWpjO6J z6A|I+-kx$-vJ&j@Zrwxu?D^?w;fPt!=NHML&-=r&gH%)8Xg1)+l&F=5rx*8bPPXuy zWy4*0dzC)WP1OuYz|IF!FS`oqZ@VHFdaAoq2%z$~#6z6XO>gvZovD8_{d{yf?DIgx zvs>9VdY^cW!&~n;x#47Sv3*KJLoRH_?}@YjV)5?p^e)X2fPQ$085$H8iR1ATJietg3)sN>D-;4I?M-tyqAIzN-L{%B7{P_Q2U z#@36KrH|K{=lN#Y_nKBg_=T-27$M+r{n41U{-Lk_;dJm7>D2FEMpu;~3B5b@I5)O- ztn2-4h?dZ!rmT;reXsa!w>u2nSwq8KHDnpb2@qU?zM zom{(X@cK?2h1$hN9mWp){we6gWNhG?<`|8jsr!?we!kY0t?zz1>qNnE4{`Mg#Pr+mjLtz+7@83|eyXSGYxj_{$+-sWYgko`jRcf~nV3qotvqq*f!$<$DD1vn_cu4(J zv4r-eRSwOh?}hi6HUXS+0z*Q-U)n3Bfx?3nn%nndiSQr2&GJ!fkRsF<)TM@#eV5Q= zl&L$f(Vy}@zNd?tZCq|)7#Tjm6+c$odj_lf--MFrwM$Z zsT@%0yT!q)h~+o<{Kj=YP(phx_|aCjs~nK!BuGt2Dc1Y&RpdpNm=~nXQ{;} zKgaic7m}Q-&n--cnissqm0`LC!zX+}%6a%>rOJ7FW|lIXmxkc_^8iI_BkMl{3+7!> zc#_fJVuu5xJQ}~9&iG%THn6~=-_(vncmroD7ubIQz5{?_PTVF=2xOr0pfeA z82In&qarAd$0JgMY#mr#w$=3sh`Pl8L8-Vk%gAl_YaodSJR;zB2EFp zcV!r-=T#96<%{}MAWU`C*78KD5V@8=26r(ujSdzIbr@fLi~M6Xp0nCzaRt|9WJC{a;gy?$@hE*UJffmOC%u%RU~albMg5&cM8m zjK8m))niF=@Xt#&_w3b^xK8%-9MTD-3z|Q9%E4&ameh?hnYyMDasK95*pzIlV@%R~ zq;uFv+t}v?^BHB&|5T|Sv&d^1Ecrgcx3e9JCAS|@v+t;bfPyMu!sD|D_)%^4%Qa!GHu05?Rp1t7D>x@L_;YEcmO%eHf5^({+~M$Nn}g_T%H2uJ?oD;3Kv&LQ&J5w z>t57$6Lw7AR6}Kku47#JrS9?l_FTNEPIuT@e6x5|2xG8Jfe{Rtz zums&oy#7qN{!ThD77b4c8Uf0f|2!GfSd?3>m7|JJ|0Yz1Q#v|1a)e_k?f5GA?e_~7 zyMJ=*MsBV%d$O|JZ(a_s-9vvk&E&c(h8t7M>%lTYKDMCUikjTKUVc_ZGa-ze&AQ!k z67Q+OgJ{C9Osejkvaom**n6=lW^oF$>`Jz#3Jx;Z%S7{go`P2U|6!$ooPL)N=hope zRU@NxWHkAwww6&?(Pk2P0k&~}NDlS6CseQa*ZG4yt=}=j2SmQNZY_&|QWYOJ@o7`F zV5MSrU!CdgC^7gFIHDZFhE94xPoDP(RwUnt$oYaS3Qk=l_)i@8*Al=qkn1j)CHFWVhuN0d*Z ze>}HW)*E;y(G!ANl`Ykl-yc77Yxw~~-=qAF??d8WU%pDOY!^{L6jP8Ds{+bUq*t;% z?qn&;^!zjfsn3>bQC2ALx0U{7fAB2#?bDghE#lIg@t-*|0noqL!L_p{SI?Dt^ooMF z=TfKDD&>C@gV%9}d4g38`d8;lRWL(sZN^AiMPd*93sOa6uYE1j!7PI_sGYHGPw#_T zzaDss91Nm-iB0to>wymUe-jh@uUrp`*TJp_+^YW#*8`OQ&Gmr1Vs|4hd^3mt z_0DbRYVL9GiKjWLd7(aYwt2r&q|F1|y+nKE4f4M1dSyfCo|Qt?aEN}HGyJZ~rH{g$ zEv^@Tq4jn~=6C^gx<-2C1MX-r6=+ybIMk}vMiBY4q5VEy6998n!Mcdhz_6bv)SbW3m8Va`t{~~R;dQ`+`#=<6B z?w~`G$^tQTTs88G`MVx~3ps zz|Kl(Plh~(gWvrX6FOh3Z$b4B%yRL&TAiNpuGpOGa?RST`i@h7PMGV*Bgb{E^))c? zF!b-{hf&G5F&SxtQG=N^wVCsWgnoq0wR}!yctY`wa#UfCLVqF&LBK(Lg-DDbjdXRI zWIX}7dkNt8fNZf4-wn4x*zbl9|4x#Zw?NqE4eV#3AWhyz*}O2&Vz1pLDC=Xj)buu} z5fctn?R@+run%OrydMNwl>DNPwSe;!fcnP1K#SdkCTz3~4HPyKID+ffzdW>6F*LSx54rkxb|*~ z6%-cmdb)lR+pydL%A99&=EC4bP~26*w_t(S&@43oGOwUYI_XQOwk~FUOp3pgJ;;p3 zKm6!|akOLs?cHGWdDx~?aB?ph34RVH%-;o(S!$}gkxu~*M%!>e2P2MM=C$s7vUwM^ zdZoRub&Kiy(FW)`(g%wQB?|9$E2EJq8eMDyhF4yab%d z1%osn1Hf;r-@g+xZtz&3Qb7RxS)4P@Sc2bZR)W{Ak{MeJx5t3WQ1B;}!%x3;3DWWK zWHQ@)&}O7_h^46g8AoQPncs)^b;^y zhlCGrsqK`e@vXdQvM$)6S9bv!$jN-bgp7=@HQgON2#EmXM56Bvh68f$MAzi2Y)~>< z8Hqt7<8xtXP5+o>WwdH<(AJvvvfi!9PvVgdf4IP@vW(q+6Ii$K0)a=z1V|T~Pd(G&sRSa0wv-`?lm@9+Jys=BEzs#jI7seYcPs!@L@v%GbE z{3?|5rudwghZW1Lh5YIg3#P3=EuwyB&(BC0e|c4m=q{7_B>3)(imLxz@@+i)F3Hjk zyi0xpJctGmGq(b+tpP#6kxIQ=Jl8D^VqTD-FIM^x_`xbOm zDihyW3ok80QhN^b}E&RREC@!lpu8^QiT%?)JZ%R4v+D5VbBt^R(Q# zT=~|q0X)Wq*ry6OIyvy-*sBMQQfLrY0d`lwBK=<(c{9JehdTm;=4rG4oO*!_BX3la^TjsKg&?ws6*iD-g?|J^krzw7>o$yIv{EJ+ed(?fx{ zlrr6gWB-Z+)uSDW8Z?-X#Eu)VbarBb@P^#~@5$f8^?%{X^IK4mg3CTsn~%X|4MlIM zB%jRf_2_qyM!e2txTBZCax5Gi)P8LdeGYhg3Lv^qT*(_)QdQ3AeXKa*WC55x7W?Ho z_W?QLqujt!e>NStSWLk$L)R{m3m+NfnL4%YmBuWM8;Oly8l&;2*p)JV>l-Pcb6Ubj z5AwX=xiKSdah~w4Ov)614(AqV2ZV2dqcP`eKzMrs+@A}A%dk1l3~twhYg+Amb`DKn zL#_C5iua&!B-?jGTbFPr`V20eVDgtuL;{Ear_zYS}i4_HV_5&&7CEB z7l-+*(AQ7x(S~trn9W0uX2Y5+CPvFHodS+H$ig#H%?s4Ed%>>t<~+f!AM7GR7+iW0 zkmP0kdZR(E@Oonr4_%Z>1%^<(Ke)64*Z)hZOc(vv6K<3F+U&q!9;#L4V7IfeZ|zA) z&zHAwj-Neha=%|%*-c92!-}0M+M!LA;(&A=fr*eN235G%x*^IPK(5u)5;{pgj+dS9129GoadVLDy0EtK@bJ6r1_ zg+yN%ImO~uuka`q+S*k~&dYVFQa6nfC22Js%?4t;gJXef|mwQuVK5lExxgqqf0B^%nt%T?8y-=#E*x1P-KzcSr*Rrc8`sjLn@v49HZ3%4$CkT>DWU7??A)5K}MA=cD|3}(B zn|w7zXVIqveY!b~L9~?Yr+%+{iqJM_EcKM`K86MaJG3Z!?&{48^@tay{1h1<8P zZ7Hrw_=S6?fk``8X0VtRTagJ?_)V9zSW&s-1vw0JF&>NC``UX33z=k9ni1y}B-+kO zKND}<=|o_9o-C9NRkQe!F1DtEWPzQogq{u*+pDsWv_nA>r3;whe5Hd)-@bBSGt>>l z=oM}4XeIlfP^YnU_QS}C4y8G?%f{f-okFQQcWgl2#?Cr$M zJM^=rdTmO4cY=UNd4|JEg4s}3R;muPPA!bS6twuXk7tMK)9on4?rvpV75wO2wqct% zH`VfXSLuAsvTLCGcw6Ade;hr# z1}Wfd0ttXIJn~xjPevaIH9#BzT>Zc$M|B(t)BNxURB4)twdI64pD$SE;r)Aw~%AN z=jKcy)#vJT3>u`rDE!1qgNC39ezqo>-lM<3`bA-TIdle2Z9VQxTI8G6cQ<(EtYGB{tmxGDpgehCuq8z=Adq1b{0mI&r!hHk-26X8czPeE=eE?#*L598XlHm(!grX>s5s{7Rz4oq3KUQtA< z^iM1-n2AofzBhOvD|?1y=PL>Y=;~phn31BR8_*XZHv%9MA|?_lNC5#+Joz;JqxCii zdb_BQ<+IdCU<3N7?F@A7l(Pk7@lq%7Rq;6TUfl*!eCptCPhY-LT;9GajX5d1go5;^ z&LXcA7H)_4%x>vBKnJp@1^y_A-SO{Xb^;_DQvf~n7a*Kw8uY^mh$1JKE_%}@8(Z^y zC!O8?3bZPJP(s#tdgML7KOtTf9`fL*=YVQ|gWpxPEtKPuUR^@Q7Xp&Yg4Pg@6~ut*uy}9$rw4{eX45 zy-vMcfY;LJ`|SZ`z;M|#$in8ly7VZwf=+A%&ji1ib7KvM@u7LpG#t91k4=Hc4T*&3 z2be>3Ume-1q@VIDn=Wb2XM>66Cx2vQB8B^wtgbeMzSvidP6Pi40;e>>xVs3-X$9@1uE7sGOC*Z@d^N@P$ z9%ee%V&(@r{BUK*`x|tTf#gzWwJ1R~gzpF>)rLQ$!n?C}w?M)}0;czP7lE;laLZM2 z73|g+9&`>aDFqBG3AsI!4e#4Pq9wBNQH+nDhmOnk$|^@YF7&?(K$Z4=5$Xh^;GuIQ z3)|OONNv|p;i=oO*`I%4uyJe0NipPb0jhj?I8O z8jlb8>m3& zV!-!_pSY&sfin7A_?wOs8hIBnf$od1rj}D!9@44{zARuoRXmn(k^MZ>k#UH94LT22 zYCeb#tyQUo*^T6?>cBr_3lPLNG-ztwPWdF}p7JwhMY?4OU%lE?u=H^hE~{7C+F#;$ zIhtCUv!qk_1$o$rJG`PW8GW}FUbI>53(CIq11fb`QQ+$xISip0$xI%umD2KMYHVJZ zjHd(}rOI^7u3Jy*BOGoNsxE#p@A({qWXB-2rcdkjvb6N3$yDYvs_R9sA7b=`MPKAG z!P&te^1yaO(J9>Uy7$zWJ({-Gjg9uH@-tojt&8?HP z?T;4f>>2I*hOH>PXmo#(XOrSUhNkBh{Y-!CNFY^BS7+={r8495*w7gixIMZW_{iJT zsF;h{Vlkw7v^`Li?J>7whu&<{)JAYK-Fsjy=A!NXzS{=kG%Bt@kK& z`)xF&B+rH}?t3*YAsxO9yvR{opUGwf_6CjZ=I5pQ{c)|x3T(I^7;_)fR}7&7u;89A zji)IMObe8CVr48{3_+TW{k0XBQY9*Hf{w>`>31#Y&S~8~w_d({b4|d&Br-<)*jXNH z;^NZJ5b^DXk1)bDZk-_M4|FQ#n3r-6xoK9Oa>MC^%`Dq<796ZQ3!|soQOT7H@vidw z7%U}6gGghOd(H0A0<>5utpUYqhnLC9=tWxGuBvS4wi(k<`GUE|Vi|>9{qJ6JPVDip zQ=zQG9vqK%^nMR)AZJ+u&o~=f$^sXx_4KUf2d>Weyx2<7^jdwjdxqLAV#2q!-=VgI zS2NO`Rl_z7PN2f@?~59hY#U$kCnl^7%ZL`f^g4X^vQi%g@Z*#Rut-v_sxT5$+%|F* znlj@y2pEx{SZeM_MYZ}HcA}RZ#z`6%Z;G{^-1ajRFpPpt=*1aRD>5YW%zT%EHm$a7 zXC)M8t0a-e<=bD!j0Of2VXA%-BE4WGh2}$bKdMz72W$ zxJtV88$c4!&zyNTbP2ko5dnY@yK4PKip>7_ccbwy5K|Y7L@zusMT=9< zoVh@07#e<0vc;pSnPD?m?WBb!@-n0V9rVQV&B7nt`ljd}gYIXli`@Dfmjmk87ZcNz zDv@tW70iW5CY9g5F<;yzid&h>72Kdjs$=9Rq@?U(x|9DiHt$roV)z%`m%?G0d-LUyU%{ z)M?KClh|6_HML?jWfJ897=A4Ki&j#}bH1qIi&f9xW%95Dk8I0TKNqQ-eE}ooEc9-6 z94>lWIg6x=IJnE94KQ*R(&s8eK$xDZN;UwK<1KNHC2Yy$b+Y+6DCYW>;08=L>bwWE z(G%=);{~klF>=0t@1>hQn2(T%ucC*tF|?E|l;lg7FC4bT8a3*E^+?zHq?A|ciYZOW z5YUh&{z(b-E@!#J>%)pQc3DYpO(!G*vf}!>9MnBZ;N#|Hf;{osGs5`gZq3{vTUAxE zeqMP^eW!Y3yPUOxy%PZ}a#=h13d=N|JWmODl_GD>2OF{${4N5Fz1vNP)2m?VXX990 zI^ZLPW*`FKb}aruQ4671X(a>XN?nHqVU|$0PZ|rr@K!3Bik_J%JuTcTztTX9rdihx zqez|UqLaiW7^C=xotJr{-pZEqyDSa0U7qZ(LmTaiimoX{mPA`&D?^f z0z#L6^XgKoX;DAVqey4=N_!REn=U+Z0-)__*x?7-Izp?TtAl6{6K9xaZpR?%o5b@j znyQ`bcme6#dG<&B)jX5}{Ud4@SLI8)pSd3450{4}LA4nM3THw9U2U6z;>BoxT zfQw)t92_++3IpgHjOKkZTQTp6DI%V02p1lr--rS0twvTaJORq>&ucH4V+ozk5Qt)Bu17A!nfzqRh#kQ>e`J-};8rIttywQ_w_|Vj@J7ohjPyBeT1ogrp_OViyh4DCF4V#7K)LAp|RPs~ejJfE@Zytv@dDO1`vd616hkP+N-T{lMk;p%%`^sIM77f6NnW^6vPBRzws7{*-xyfAJ}W;|@} z*w66&)J6uqA>HeedfnXYlT;S*B;GvRztPK#w%@2Z8EMiNk z>+CODq&8=h!+fbx5gm$m0Pk}6GpY=KP8{NN(vc)AVxWY%1vGH=sexwdxo|IGS`Jn@ z6YvN2RxSf49(JB_6UEQ;0}IQGXOhLk3X7#cRcfS|~*m+0zuMh&eL zrk)}{0yJ2)OiZBV+d3{}D;0G5J|O#84FD%z{QWy1J3EPCfIAB3W+}Yg#UEcRve^+cwhPy@J4=>S(iaC`I+!;z&WB1F&VgS*#W_ zN^7Z)8d&W7F-|cspOfNL8OJ^DnJe@esjwYi7xgN^X>rxUH55}WYW@ObOYs%?3Zbx6 zQqc-}t!O6YO3FG@F;zmn(|43ptEWEC-ER#FW-o-#vPWaD12@IKq#C|#Av!t!Y26)zpC$XyD=h1y0V;fKrz>h>0PC3#SF9%I zfODSdGb>B4W(q-^KDpuyyzF0_v?G{8+X|+wNxh3^ov%G-D;WoGklFDe5TX2E2@Q)Q zF$A+y-w2fXm18qizV$-YJPK|*qI?g9&*E_*L|7t`%5%QtOEFWl+dAL+WyYVjA|wQGeqw}XTX`|_tUOt&4gA7d>0F~HdrzHIn%!@xztth@H{LhOD? z&IPpOTdeqYLPpVd`<8;%w2Ckf2)?=tC??sniZ?28o=?R*@J1H1K~c#mijhPwWV!NH z6`pX#amzu&-c#zU3+BcM^H}gV(r{@z2?nkp@gw8zMuQ(rL_LgM1*<39NJ4BkpDtXf zuRQx4^#S~!y+`(zeQ(u#_*TUVG3Y#v0>bc5_6M{HSZPR`gUj@j+2xX7GwBxfje967 z`9lQ&{sPWR%1*^qg8NR`~_hbgUxHO(XSJf|JQc9iCxg*lYFN+dO*Mw^|Tz zeU;AcEth3*Q83A?@e=uvXPQ*;BABx5&`(n$FoOK_xoG{IJN7f93c}M@fwvaP-zW}e zDkwyUA5mpFEBOnmpJL$8vK>L6iz^35O^Us|Nn_j#c{1=^REyAbJ99b5{8x(P2&qz* zvtsD$5q#x{%SJYl+ON-x!*C$qOSs45yq~5YQNGop7qb{3Jy4j>@_w38`;@9U<}FRg ze!#u(GVx|GdvbKzH|jicR2+hWOTtY-UlSZD+PUE~uVwJjaj;WWPUW=94H1U zgp&CRG5B>RbL>owOXBIOILqx+;&sF$h4sDN^D_Z%C0{ns7>PO781%>>rYwXI4bRj% z^011+nC1*FtE?gUQI+x#MvTM#T@?+6{0#+-@6Lh3@2}VN9C#bi?X=;C_!!v(H=l$) z02G8RP<10dE1Z_nFrA-jP*5^vcg#Fu=NqpG7)UOQ{01b{Hf9A$$fg^zbfEfRO#%zC zt7JL}>wR!NH1Y8IQrsV@GUu=%<)A223@<>qx6Q9KZ?^EfB}Duixn&pDhcDJ+Ldvu8 z8W~B|LY%G2_=2j*nbK|r4Ii_QRq$cgv%bhfN&*Y~1a1T`{h%IOPdSJv{TiQ|5K#JI z-({^jC!ifNYrA57l?RV*p*riIg)OA(p3EyXO1d}RJau`RPrXBW14eKumhnvYu(kp5 zO#4M(%j5w}ANTPMIP)9?_UMQ^2{g-tyvGwu_p`3lVtAMyZ&FH`(A@AOF6gDcNYz6WL-1Fyxr;hu&Ij(6iJuv zE_b0Fe~oy*DT76Gj|Y}AC5r;)zh4px?iEb}a^jhDMQ}UrL?b|-8qt=uOTbW=BJ^#1 zT;D_8S@+WCP9KJ|MdDq~8#wnrG#X@2kGZT!m*=ubHV@Oi!5W_uKoNQhd80c_`UdOK zT@E(+(C~;z>BsIKDDGXyZmRjlI;6{_#5gLsQ21p$3kxw4-GJq%0*G+t%7Ih#8Ni$L zBKavqI9>teTzDu-d!r-|ga9SpuiRU*pg;ndoQ7{kYS~pa<22UzhL{S5 z6CC))^-^S^dDX&J)!Lzaw#`!7xlf>B@EPgf8fREhytRrS9U*hs z&j;xL*6c+yTR61-heC)d8oGS{H`Ntd6*VNz&!_i)9#=R=sOY_w0P&M@SWK8s-M)FU z;47ZciDJ0CSZ_qs26p8dv&ht|?mCw9=OtGb34FJb-2Jl)SUN_H4Khfc^X6Y6n!6p` zlREQQ-2I%zM2Xuu4P}ZFvMrST@{jn*F@yZJSC7Iv$m0925+h9a#|px%W|J*7o#J7F zU|&XTLp7o`Zizma)UE6xdSIIDal%g}F$jWb&OzW>5k z(@D%mnP;5TZ${-w)jxoYGkV5D6eTy#rtsajANrdp5`2r`Z>+~M4&TgiD3=_eWX`a+ z%t^JE3W#8QuLCQ(J5wNstQlse*-tw-|b&%kvhb4vJp-f7_YBI@+Oh% zqHWgJA{VM~tbv{mK33hTUaU9h@@VE!aL8S6eN|VMFXy zoBq61_O%RuLZ8nyxzeS{W1)-~kz?(J zf;+H7MPtC#PWIwk@|E-;OV5wi)xpYJ6iUG~VeG!l63Y^EmbQ!)&{9*N35WV(9k=~F zSN!1sZl*R@Zvh)>OoKkHtCKOyRp26X$d$Y|r8oUTlDW53#{hLD4gQToE^ePD?!t0h zZ8usm6>{qu9!RZ=FZk=YX~!h2>0!8AbdX&awDtK7xC0{HwW$Jc-R8=L`;yq2NbAz>kHLc8DibeZiQ-bAb>+s9P324nkt zT#*n0AXbYzPlJ_#n3X4mR@0-YzXYt^A0aUmYmjqOe1xNa+qa=qtxYC@cv;XH`O&*= zIwT^tm~p$qG5rk{5oD56Rn^NQMo3&|Q>Sh}gOG zH>$1~wb><-c=R`H&QyJtykhP2!|N^1Q6G)9sat6Al1x_OfBs-NC5tZLy=9<u*KTwtnsxY%$CiQBBkfF9johD;sCVami?OZ4J)#B;fJ#$`;#b-{S6!68 zlB^uaZ(=WmV|GmV2WNo(P26dKGs*yX2&KSlj>{q@ybqo5!{y{^E98Pcuf62s$hvIa*)K z1PB`)8>_o<8yyk9(0`7~QV~bxcYi1sxTHF0JDi7SD_JD%I4pyVIZ=BW^xaxzouxOG)iE*pA?e4T_fh_xsmI$I7N^=q zYyJ1d?r-t)4deSh#N=K6^nW&kd+Vg5_&Gj{ly7G6Q5ce%6M3~*AWrk*3jctQLzi_& zNba>Z;&l7Y`7i6DE(ZMFOw2Aci5{20t{uBGZf2Om#m@i2vjm7@ccWQuk2Pl7up%S- z(`Lb+ zNdPPELkws)$+QwA|1cynT`!0Rg}M-1D>e%qT(AhyoR ztdz#CX%oy|Zp2M3HwD#?E=npnL%>tEXR))~&bo=Ol$xD7Q|sxcW(BC~I`_MdjwCGS z4hKD`Sbj|+K&}B{DbQv{=N{~j?s#f*PZw>~$EFDkGTJR)tq2GW=hBYq>f95~XAxT4 zE7|b?mCK1I#&78}ht2%pdaVOk+qd?2H9}LgOl01zv4#9b<*L{v0R*|vsb4G>XQHfX z)ALTCocemsU^7&%=Y-E%x+`a0XRGYRv!v+8vT*#Q=<>U4`HT7y7bE4<`qBCeV3Qk0 zr?dfIIyrU`>^%gMzjQh7BY-bMfb9Z6z+PsdE-s$rza7hP0bF#v#{E46f1EiOTrR2# zNK7~D7bzi3*`29FnD#?5m!tS1LYJd5Pq4~DnWtTa-gGmM65IYFNj~B%#)OwH!V-}u zcfuuX>nrXf*8kqym3WH!6C>QWU>_vje(lE5GYiZ|n2Fv!#wAPeEnMA~w%`#16#oo{ z*%!J3zguHL7}kb~`lm6hj+is|SI8dy8Pba)e|0;_KlS-8y!x+hZz65-`|~8V3gkhj z(EfHme;XGlM`?%pOn=78U|*}ipZ|flr~y>`=Q=*N|4{MHt^mXN8D@DHb|cZ9sVh+G zU~T!Btxnunrze=}C+c|WtjI1)-|$T7mR-xQ<0Ru!ZighdOCVQJ(BU-uEMrQ3W1z&d zF)~d3M=GUG4wBf3Us6FMZ;Q2Zb$pPubpaA>I!RJN3$ux{@_D?tlWoEMiKyfxOEKrB zH3aNNKv0{1BeUAceZfEw^vb5-rhs87yvI;#eJ^L14Sit_vGbE@NvlnK^~Kwpx2aOB z@{W$KU|zTzqOLAVyOR5zC%=*kz%;Mjj3?qd9+zUG2Yv8qP7i3Pn2?g!js@P$?p+C3 z%7_%*Jb-0<+91$XoEyh2&x+ScP+`8V9JKKsNX@IGkX#WVvkr2`Z3d zlALe@S6YmK^m|M5p`Ce3-sOuMoqH5SAp@hHAO zn_jfh^-uzOA5~@f)$hk2Q93Kc4O8i2Q+BZ8p)mpRd@)$WDXkv7G zjBZOi)ktOpjc;>wqI$H1x%!OKv1c^n4~z6KbFJ6^(_G8yTx1NF4F(Bu9j};QZ}ak6 zOpA+E<0q0OQX{>dS-|;FG-2|BH>G!1Auc2ISG1O2=a62jtQ5zGkbumg!f zFmXrrpa9 z^Eg$i)Iw>rKtnl!m#AIbJFna?CM_|C1C=c_t6hc)I=g+zthG9XUVA?Dsiq=i?JFN& zCtHej2eprQargfqiO9`7Q9;b>T79~E9BHwao+y;`9reEtLuhSIDU{CN&`nxrem0l$ zA5V#Y_r$lGPwzV1@hN3~UvM$h9fkOW1`DixgyMG$WqtYNYQ_olTxpNSgO{dLRNQ0O z4bw63qRDXcqa-79Il9bMm!2Wq_1!2wVI$A1xm~^lSn1Q3&ID-HC|Q_HGOBddRVg7+ zgzA1)&tXP=Wdd2`4pXb_x%Lum6(?d!r_1O}DyCR9lQDG1FlZ@O^>Qo+Pu=6zzx#2X z79^yxPGKs2nzY5L@q&9uw@%4AGEa2nxy#q1#&~$BzG}Gr>1r+ zigeec3zL}i%;b|oY9SYG3{-sJz$V#xpv22gadcLka_Lbn*UVB9Q?=RbQIbnF-AxoW z=|!*3+dAWLFRQuHF*?HH*XF(Jhiw8HdP(VRFK$4t1GNoWR8ux32yq7&8o~5Em0f^- z?RuR%7XT-}K`4@>w*el^+I$v@i@19y?dcp(yWJ5VSdV5ja)86<1{ z;<>u|sfp#WluP;ZGr!p$CM7z|*6DBXXVqO-Rw|VIh2$FKNmX zLgVoj@HqxP2uC+#NFhFp%5aAQdsAPLph*8lfeRn>3?L&7lvucxJuFuPV})9ZJE&l& z98|sc-8Sq?fmnXJ)Ak_P!%3SrwIZFoP z_Kv4g)Jx~)OnMg1I!2Zpr%gtQe1_P|Vu;Fh2sP3IXp@b8`z5(Ad2S7oPz!yj%y2zJ zd?jvxPCDhXeM8kJLfvyJk%KD1*Bmkg^7f~7YqT8xqI7HH7ilDmQRG|K<0<%?v@(Gnw&G)4t9H49mpr%Md{A3dI1x;* zvE`UYtE+lNW8y3$SgG$QT_L{0tn0b}*{_#%cSK!h1}5inII{ZA(qZd;^0QqgzHM=- z5wQYmH(KAJ!H#t$?uT!@jL>Fcn?@2n*n6GeY_h#giN*1)F_|!mlFpM- z*!WlEq}26F3Jdh*Be~~y@a->0+NJmDu+tVSBhCP#54w1nSOpa+zlNW1AfVsC*J!pL zf%_qN1C(M~gxTAO$qqhi4iv`;{qbl6gGlO*rKrse3ib{8Z%lcze+1qkH;Y2)flfKf z|KeTrFFn*@RI70)`q+!d(tH=YpPw71dw>iFsU`pD<%dwe1l+8@5MLEvFBSrKv1_pM_3bl+W+|y0L)33yf%VY#@2VxJI{cqiOVTLqg(G}(-tJre1!r1EI_$ai zi%InL%BRcEbf#5k1agfQEoSCbo0;oEd=tAbZ1+ZEZq(^gY03Ic!CqMTo)!M&BRy$v zNMM3H^2h@vY(<7VN%2#}uCL%VXt{KSzUpPmY7Wvko&IN}$*8Th_CJ_n>KWtm`800f zk)Rx7@PQ1UiI_82EfKRgPiBP<-O?;R1Qf8La@HP5pQDVJYnI;rnQOKYDI-@GL7IjQ z+R9ez#1NvT_Q)s1q(cgrnV}h|+B5a(y}5520$XbjZGH8F=n#0`w7KSN8GEpk%mLNL z0a{y3Y%LS~Rf4ro(i5bF1g*cLcV3Fl+Un(Upr;QQlfQu^P3FTYZ&fJ)`=|t5sfEu1~i~k1*{ch~* zMyt9S<8jviC%7F^R_-68|7dyjG*nk7BGI*Vx{0Zy%j!qXazWJ&@mBbh+nihY5|7^6 z9D(?~y2nC-+h9hX^q3Dp$#QLS76@`<-XDv4YT-GvFRXrx@4#T=}N6}AI-<7CJA$QmbeRSBo61SX0kXa5V`!t#Z%#1&#TiICVrY6B2do*}xo z^@d9gRb#PW-an!+i#r(-*c(09&hG>^XriFF_dD>C%*Xlv&-}{1pT~#)1HW=;IcmEr z|6Y`ipD5Wth5F>bsJy0Q zX>c3T@$;-{YJBWNkyx7C_<=&~!`R(}qh&R1t~6VQ5Kh}_AKXkZ*-ZV8sf`6Wogm~h zL$0Ofmov&Pt4w^gG>*t&0z;=1{>Pu-@0cSS3hGm;QNscOZ$GD&|H`l=7DnR#7lx%c z^p0UkM!bY?MrlT)dDoAHY!rpqe(al@Ug|ObUSiBkI+&}>qJH^4`^1euF>45;yK=>? z!24B`)0XO~j#LLvd(7Ucz^n09z%%+bNKwcw#BRvqohB}2^+I&8$nkWSewjVyZ(X^w z@X;IUl=T&oD<5{$C6$owg`pfM^_RqxofQpSER3Dj6~6vwtOn}l2w^!4X_4J(my0AE zu?=k&&K&y?eanQ8J4F*hB7GbEAeA->l5pF~&;mNj<^QUg?eZ_QOHqkN7OTfgYeAU` zND^hCZCL^tm>#jv4IvxVMT`(T=^;neC)GK2)yZ|{J1ulc+xq2r_C6FlN-SrJeV~#Mee-uWoLE5^S z*W-i?O=XF(N}g$G<-x>$=MeSsUO^S`UADWu386H79SBpveS9HY2JCz{L=SbTVDc2|Ks0THS3Jj=F#bQ!k%qr zqqvY-KgCei83ezQjUZaCemyDhz98*38n>xU*0=Z|0R9WL3rZpaLOv->B@N;LQpd&e zwP;~Nzx=m;_euLCywwpshYPRGlpNso11%TwY}t;U%VOk^0}PN(j* z(wvLyB<=^p1bBZlLFpx70Xb7VH$3hNfqbtSpM#_-Y#RpeNFA166Z#>IW*5@j04FTA z9=TRmP4#6B*a*7ESI}`Wg^;HoloA#>&5A4JA{J1%q~%WQ&gTmiAncSt&_X974S?lM z>wdY*B20JO7##R!`d3tbODY`IKSVGxW`aXIY9VZ)LRuMCeWyW zcPGZa0DS*=&(mXitUZPN0zz0wZ~+M)NB6wx+6@r$R<3TlI~!ksdjS!{|6O7$4+l}K z1P@_~5C&xtW#R%t_R4}k3>G=>VES$UVMBit8KeF~WZaAzs%Ne^egE_C_Kb1~Ja1C}^F*|I{`Fk^?L2 z-e=g#`rAn8<$K{)`gY%mTM=PD2|^KUOhR_j53$zwA^G@$ttK zj_Z7GFL+;)oCUL9m3mvvG`aDY9bOh46SovWQtnk7A93^iTPBhGWT9NgAR^hrLzMjQ$t#biKwLKM zb)6wna|m6_z{(&(*8;H_fUyy}*SXrE-*6{>k@2Xl67(?HvcB9%#w{2uYmm>)d|+Zl6vr91CDGj!=5z@s4u)ZIVTFbm zk;*-EtLcHwjq0w2FRGa`a?%NBNxFSg>Q^|CG1OxpljU;btghJu$avYbK_WQ=}MvCjcTY>diJOU*;e zgxyfX{=J^7OjfzttITCLA6q=tcX8vy%sv-rEk9^u2y}1vA16sE3@`cI3CeYL;TPXE zR9?i~J_*YCSX_0!kjOp~6nF!=mT~a5I3T2*X5oRf8-GABF0VQKEK`NL%ZZ#pP!X7d zb*6$GK?RQuENX_%2KquEqgi-HVSH6SGHbabqN&1`t~%X?s;e4^Z0EO8StjpdSjS<|D0ar#^hRH80c4n)I_p2x)YQqJL;d z^C47`=GS8#f1Br>|9zfE+hbDFWN))tXT`dZL@!>uD=Je=lQ<4ztj{E|YUP2LNp-kN zn?sVF>|Ac5gGqvf?DN@N8|EE!kBgznUU5jy2*uX5Qvp611*XK=j&<~F9#RE~nDmQ* zV_#TA%$77&%d-@n_@!qP+s8l(4T$Ewg13<&FY&_UJ4u^b8l$IQo}HXJ{P07S(iOK} z7!nu`rgy)x_Yx-Zr-%wE??O!SGLyBwjI`Uck;jixk2xm1sv^OB1*VQJshe&ddF?49 zbffA!9e}F;g7`t(=T}j}nUc=`G@(0xkhBr1%R58H>q=0$M%XRQt_+)WeXP3eP_Gg% zeJ7w18|nQ`x5yC`S4ax1a(xO6Lc?bPOtt~r|ACwrs%!hV!o|hZR=nm5}&by&&u7=2%Oonq+;^eH;d4^J0 zt18n-8#7%%k8pksN$I%jTOItR#G!<)Cd!K|gLJW7LdJaTNO4fVE z-D3BRL^e@2`lYgDNqt3_{#VNTNE16z9MXx0p@TFFCSbDLg~!?T&Pmp{V(aS>fxc$y zoPyJv8ih+JVqDaG1;_<8jtnABl|o?@|5bW_awol4YOY`1;&6)+`y;(45eem>ojn8# zQ^Yk!dwob-AX!J_JTq`SMMyF0glfOJcP zba#V*v~+iOch_G3Lcde@p8uS4zdP<2jJf7sn>F{GYpogY^E~f66;#EydKE%sWvC9+ zwsMa#g%In2UM^9xuyxQZ6E!9#qOEor^Aj`8XkqJJ>_`6^Tu4RO1a>`3|S(Rz0Cs z^rBv(DZ5G7olS4|A6@NFWoH;wvKb7HNDGvoIuwOXNm`QhBt<%yDXK?(=Vdui zw&SFDCDt{RbL#L}*4F5h+u=J>N3Bwj8d^i8GT^>yZ+LLmZ88WH)9ARqg&;}-k>`8uat`=#(zY)aQFQ)%Ef!^ z|B`Zfmi{Lxmq$DQ8Rfz;_Xp+TsQU-yl0h8tzolIGM*g5&4ov=ma%qQoZgfPW^yerS zF&z|~>89X$sOxN&cw8a@97W5Jw?@_3M?P~Y4If^K+Q17AST$)7OVA%K&JBN6m$Mx| zZ20;MEfP+Q&p3P+^eQ~jPNXeh-K;qxCce&&cC+-H+nNhUUy0IHz;s+VB`G6U2UFqs z?9^CpiL`7_N(ucAGUVHFXTz$M1MYm)?Hu)UQT%yZr(q}$joU?2o6R_xn>KFkgQ8i0 zB?YZB{a$*n_xWCW54{3F>AmH@mEIHW7oH4xqiFQ^k+M~!n z)YnCB>y(LjXnw8D(B+A`XZ4FZmmy7m@g6@HSmqWpaFe((KQ|t0XWElwqKry95zZQA%c6F>I#8d161I4qa_MafY-nr8;DD*7u0){NKQTg?6x& z&wo3Q=dc%_^}}H%{LObr3O#isCnF%2`k@J z)4d7%3-(vQIvP|@K780J^0@PaAw4JURfw#EPR4OA>o@B!nTr#tQ`5Eh=PF;B-ypAz z{&MW_3X@se3jmM}5*H{|I?1!X{#9k8HQEBhD=my|dDu~ogCdm3w=K&qbo<-?1O7ol z5Ws>TQnqyLyRzlR4W6gKSOy63fdi$`$%rn(57()$0!SWfFW4#lJER*{)=}90BAFK+ z@m~Vs_Z{iX^RI8cGdz%R*yMZ$WfAJT@iUv3(p_PaR69(cwhKP#F#T@+z2gAC_{bcI z^nqKf2||h;$h~lBxA+-@+>2W*c*Om#+2Z&5SGLJL_BZzug1Y}9JVf%paW9OC`oFlB zm$wk+@I~N68AwUBHDCq!hXe!H?XdsGzJwzGYbpW!cAF}9pPT+a83?$;wzuE^rdbab z#V{Kgi|1q33x*@-1@Z_Q@Tz2tk+=w}heoVH|Ky6HkuJ~V7Bs!=?+9QDw(<5oc*Ny| zZ$Tsbm2JBF&AQA*G$W7=zuSTZLhIB=P?#*m*V%EVYOgVEr-su~iG^7lVE>?AHX;jv z&Z|8)L?BcmL+JoG;<82ApE`bm7IyE_(upNp6I2ilIT*p!qyX2$OETpdc1m{tek2@b zirC(#@J7Y@_jd{gfnEAvGBfu@Y{d%?dV2jG{`b#cq6z-Ie3(AMZUW&uc^xR16`YU-|PZ!J+T*B6j6_m9ywhFhGLT!btQ-j2;kLew_RSFo!FI~6Sqsmo@% zz3+MLNRZcN8ytz4kU5;Gwz_71ws0VRj#b&8Ms1Q%*K95lE7AU5%|=_cbajBe`iu#Z zw(Rl3N2rxMC+2HM?~Ui{pWA3{%x||*E8a4!UEdF;D@7E0S^Ka|dTF*Y&S#PYWFdaR z0TkSI-RZ@5q7hT$QHA5j9&{;`p}yu$37k0UJYxD<2%>wtY)Kq#A8j0Yo@GtnTlDWN z?0Ls#>>)PRj_K^SHI@+^a2jg1&Cnv6^X7jRisSca`uCAI?nl%3)sR0onhtFIZ8Syu zdky8lyY-07OysW+ppyrbc6QXn*F^Ct$}5%oH0#(gQ1$2^7i#bVO3IQkhJQ-U&NP#(Y#5=;fAR#F%rAlEPRuBPI@ufR9Z*&(nO{q6i^6v!*TVdmEE^>$&G!? z#vV=Kd1!XJ$n~rhC-DMmrKPWg`h$UJem4frTAC(?yrCt4O9a}QL-f6^+K@4=z&~u- zLZt15li3RGBr806L|xEiU5ev3bgAP*lzVI2GOulA-*Ds(U9j)+)T+SW&-ZlkkYF7d zwm^EmJ z_ha`N|I|Du`0e4~Jr{UC-JFZvGXffv+HDW-sTG#xA4C}EVRKdBJP}a<7g>a3mLFei zGwl}rR=K+`q4>D!imJ)7vAepsBcjp;*2P^hv|O8u0Y{n3p>Ml%#1U076l7Ptl73Mu zPx3oezuKfY<<%UkMZZZ zSRUhGtb*GDI8gCU4N@sPN#5<~Z$j!mra$`4raZZ4SWM`X2B3yLtEz@o7Kg!nu;J?b zfB3OD#g+rLgdrOxfH4??0{)zbi@}U{2o=|74E^s1YWp}?4$i+HXxS+d7|j0hMD9K^ zhvO^T7lsX9TxpY}E_1H@YE~x;UITYH1dG*+!Ht=H)o;HHZb~41V00PrdKNq{kA!jF z>|nw|e%^e3)~`+c2K=PGL3ZA(bDss7R)J|B_Y&b;JKwve`(P-wz(CDis2u1+aHYqX zVhFml_*P@y`w(cHK^LsVTOHJ}+_CYsq-4^5L%@Hq1HUMOe@sMKUi&d$z3+Ma2ZH>Y zKKLhB4VL{zrT_m$b!S$Z<)%13QTF+yphR3^a&EFrRXKP+=iX1fBH+|Zu=(1H&7O+v zsTbSXS^vveOz_joTQo1WU)iw$iPLhW=LwsJQj_8?UQh=*(IIlR%car^A7aphp0{!! zc||#>ZV&WXd27CbYf@ltaWuAXg}K6mez@f_!nVSL&^YGLgPgVUUZDdLFHX5)%nTiI zW5*+f%3k_qIA9e+WQ;ze2tZRM%CVa0p)ND-CdmPTg?Mnc`ij7^V_#aKHQNV zo!dZ!-ke<$L|dm_H7vgg!?ABS+6-!Z{mYl8AWz2((tbW+EAJ1oDMeT_TC~ddE;sj{ zH@okpO6zOnT;;(OB)RGX!6VX*OlkeI`z)B?n^QXAYRR#mKo{<^7WngK0S==6?DAdc z!q(`5sOwO&-)~Ok1_G{Yz&{S9it-zN8Sswk0JOjFWl6Rl7uW8VwiKk)L|bY>xS=H5 zvOsfkghhe+*V$ZlZ7D7Jcfax>RN%iYiGoS9Hz$PWuf3(BhfMMB6C`}$~DmbJs1CMJvJx9*||pg zu6;=4-bY2%$o4FTAH_WHq5cpb=vPA5s{_W7dHl!>Jn=Ftq5h`*ojQ1$s>oQ*{1w8* zoctO2P|p2@;?)`~N2y`wL^8Op%uJY;eVSj0e~W3YpWo_g8X@n`o>J{hew1EOeu*Rhx8v-O%o9chf&;3*)Em#f3^E%t302u@!T3 zIr9YjiK~*l3*)nZO}L-yMb$UU^sbr=N3ouCA%}hkt$DW8?eEiSxH)7+7yBanQJgN1 zajOu88xIl#eirK6DxH-rQXrZT{Us#Yr+*(3O_}L=W1JEktv2X7j2q8q2FN;vNPb?N z;k^}TQ!lH?`7JPvlpPv~I`%@ zlD05_(w2?CNLvhx7kgCxKh7-C^j!m1Mu-!lY72!3nC?b7Kui)Yo*M4%hhLRwvhUf$`@CpGdT-LUX*UcoSQy~gi{8p~IvG`dp^v_T=Lh~#!kC{e z-TU7qnp88P4?AgMy!&D=xUSi60{Y4fd@&WYhX>^^Qb8*T%`e`O*w#_x83<0(*mcDE z&-a|l$}n(|Gez3Aimd%|j~TEG3T+-12TY8X*^Ei1u_T@gV;L5C7?X}cl;e_HGeuy@ z1<$vT`rlOZFGZ1B6%no*V1n!o$L)+;zqt=hOi!4?(gnB>Frq$1kbYnPxhhF`M)&#q zMG943YQuTNM@Lqij;u9(4y6@I9-&oP6+X>o7YP+UTKoyZ(>lauTrb^+aHmm?C^?v? z`?Ip~>83My=A?$kJe|PR(6f&0g#C zu||ox8BP7lXi^vHtd+l0V5f}tu4jq6LG7`ujiYpGG*J4j_?WLC*A17#7Q=@WwRD&Y z8h752)$`H5lWTi>-+u#^dVRkA`&t?qw$CHLtaP^(Jjn9yE3qn zB4}D{&Y|9l%U0*b}i5jZ~dwY~AycCq62IpHT0{ef~b!c6yEPy9e-8S(J`-)Kv}2EI@K`CedYya_^7St7CaYCT!h}SQrv=xA+j8s zWnpkO9mTuzv~M)P)TQyNd5$Adn2+0aR8P@}ytCEl(>zWI0;NtlgFn_J=J&n&E!VaH z^8pHOB6%pH4Wo%0f&b|4@chkoz4E}AQ5lYgLUCy0$5s2sc38m}YaThuz+056>6Ovw zN^^xdbgfnMk|~&cdqutc>W?Zr!jm)pC1;Y8RU#=Z?$Rkf`&r+g#HHs|lk`x?z{cka zh}@)~2Q=qswby^$4&He~d^bVY`xyj$(u%)CAP5uKgtMe+1ydJGBHWrSr8 zT_doM)nG()%RXO z+@ga*b<@&H$zD%1K70<+3>b`e?g*XD%6`GG^pqq$28@}+iSY1wvrvP+crjT{*pppY zScH)6x4NjL!py&RmI(3$G(wq%=Q)zjgfcItmfqaP4J0b1*jY}57*t(66J*I+R-4Ny z$p1RP7qRCrN&*AW^vNl6h?8vjEH7Vm;1qXn)2jkifHwG!tCTz<-SeS{p}(TqOo$rN=6 z%$^N9Rl)e{c-d^)oE%9jamY zYJcX3Tybt0;Z->*Tr4jGH)Hg)`NfP7-lmdQeEvF!q59$9e8@ciYSL5$V7Pw!pIkIw z{h3SpKl_1S^%yoa5wS^}>cBd=m3KVHlrMsSNn){MsoCN%p_ou6aG z&eHL9WWkg=$1?E5^g6K;pjP!N>2%ok2E96R3Szs&mEUaF0kH%ji$wx?L%IVsT#ngs;S_!F-q8{#J~3__r^KZ-(>!R6oHcbidPAd5b?l8qEBdaxaz;?)sz} zlon$n#g*B!kD1p;Lc({rVw*(XK@ZtGuF@#+9Mv?GBefmCIKWa23?&iH&I$~0Nc*cO zUrtF%e=ztzy?s|t&}HD^^e~I7=57T7t#;C$#90f+W@;U4S#+>_XOgFXB2=Au28dZ{ zUqps%1f|sM)V^-IY~|Qy_1LR?k7J*>oc2?v#ilIZ;8uvFaFf03@@BjH6?xQry1eEy zpR*R%_vO@Imzx|4$5H1>L(58ayA&kjuLa_7u(j~UW{Y=qzc1giLm=uF#;@5N$xa?u zPk3Vbs~!uuz1DMDSQJ>Cb#PlD5KZZRcmSv|%w$>g!F?*c0Q!8>kL{gXkK7pXR-jEZ zi2K0I1+=LNt}R-edqtGz{lj*`&)HRuKL1JQ`J<0*ONr%`8cxCp{F}b^_gymSgIJ#H zx9DO~EiCAFL8!#)g4@{@i_qML-!8Y5?gm!&^Wz)`3F~>bc7Y8nT8?a|OkS85e!1a2 zA}n#|te6xwvB}$!e5i7(bbEZ`Vykol_gStzf-DHqbB-zrAwRp+SFSNI>wuj%sDjR; zcwpWKuxWtGMp5WMms!AViU!syFv|iMMkL~O@jTqaQk}}bq@Oa%Ra0vE!b^bnZwHRQ zH72+CZEi=nHX7D{Jm`>D*de~n-S<>%q;i+JJb7Nr7%p})wlSPoX)w%z_|LoHPnfBE zGq0a8&UsR(;6~8!WoU6Q;qVyu+2e|n_a0=ygkr-HFA>PPxdpg@m3WNWmzrN;=&x>C z3>r7W3>Vx!a;vd3b^)7~nD49BA&KHyk=o?Iiq1P;XeUi~|7~cOF83H#Sf_aZ z6)_w(C07HLzG!gS{WPH=;qFUT;kL`8*T$L^d=D81-@~B%sicY94!XiDPl{|V`T^(o zh(HK=@X1Qos@MlclN#%i0%FMR4NpA=gutf=Cv9&_A|iCnXp#ZR*xmVdgUgl-8?&;L}i z|B@X*UrMnqY#<>C{a|%!FzP}LPvfH67LmOJGov^>!3 zQmPF?0=1>){Q~gcQvT_C1mjL-2PIwRSy8=S;i;UxVRI>e51XR{n&lg$k*1NgS?w%D z_lkVDPx}yHLx+`+^w*CMM1}$cZ=VAG_tkjx=N`}fQ*{^G;30uB6lL;2ZBN(BZY~G% z6(gpE_#s4O&=FlUb>9id*W$ZgQP_j6&}O%r6)%z8c`EOz*UHF-SYC)S*It> zI%b zfUJ57dO*^aOxH&G4gIA~{q2hK0h+El(|F*cPCMhzvEnu@21oLGNP>rcW4lI5CA#3( zxE0!paQ(+B2A}@q3;+1}CN%AYI9#6seDXcL`WGq&S%qZfp~4I!%BTqddCV!f()39a zvSFY$8_;9N9{&Om!(DrG-1Hs#oi=6!?zr*AftZqB_(djZ+0UC{1dUbqtst7?Mmqs) zXRWY{HDyNdtNEo*-)T$pi(33X9EYpv5k35Ko%6nlp}zeP{`K&huh3r7be8me+t6Nv z;+Ge~UmN(ikLkSqEHNfRpd~e!X6jAcnVp)MTtKG|GxZ&FA~NO4mj4FeWUUk3ifpOG z)^$tVG>E;jkFUGH`QqS91~pCc!ZTv;%sA-Tb-8yhEx73(Z%1pZ0lYCjK*S*DQC1L_ zX;a5)Odiq7yMI$RRb}0%5NoZ58Mc}|M;q^?MoB(qY@Cvap85L7>2$|d$WEZ~bqt^5 zDZUN6-I!lgA$>UsPxF(Nbx|)5cXYF0j9=T>PtL1rB!?zID7Cq ze@R1($#~Pue9a2O7h96J%z7kSc`y?G?Ch@CJD5@6zp%-uC~@Ts+>KzL)y>i+Nwf#o zs&@CsrDZ>+cGus^D#B_TI{O5OyX7i0m`0k_*)GBjHb;uQk#@tUs5jEO?=XDsB)=;h^a{JuDaZjFlw9%zx?x~xvwN)KQ zP1w^}->Z{1=$2KlodEwggzlsFJ$fiYRtnY&+pe76xB{P(){f8BE?rXjEuS zxiQV!ie6AuXa}_jG7oOfkPU14NPp%5eH zvRQ=3!2GGH=~oBmeh$u0&TQbVKe16q;h^rHP_C&?0>eT`l<8c|u991Bd5-Wq7%BCSYcYz6hvg~4pdJuKz@!9BF5uTmn$ zY#CIjS|eoy5?XFuM|I9i7jaYU)oXLud0y{=M zI$BURD^-u+W8%sRO*e|S*hsgM3l(GyvZTA`*CcsEuSENFO>c9r{J7w_TsA@}H$ije zv|tnGbT=*$lob!XF{H&hE-`-Mq!{vc(O$h6b5yfuh09iN4L%*|ow|xULD^Xv;mw!foFjiSuW~WS zLH?1Bgi?BsYK20B;I&uyMBJbZ`}8YHlXj@3amg4-c2R+O{&v_U3BjSY^|uJ1~qb@0yy#x%g4vD76CRwmmX+;Et7+wSuCtM6p(3 zKaFXnsE>wfztNy}gh=+VdOh7iF-jn3xK}MTxxi!^Y&G83iy!-`j})}TvGJzW)_8rX zLQh`Nx2=l&9C&uDdn)VUwAei&&5QXZY)A|p(XER`zT)ksE7^TK?;)Xg*KA}CB=n5l z$awBisc2p^p5#*e0qbET>7lZxNE+| zMaW`A?r9qJlkg$2ml6EQpC1G-rq9!7DPhRhARPV+Xv*&oXsU^)96H{o!4W?-ulo!; zI>6FCy{t$T8qj4XC69r3=_n>^%gN=c`=-n3s*)(Kbq+5QHn(*FRbG+(Ow3gqPHV19|zdP2ynJ(zw(~hIh1ZfH%W`ieJQmS ziA5Fx=hrhm0)KT>cm;!<1%~sc&Tc*~$l{(ubg6PpS?M&#bnGTj$PiBXeyiP1Bdj{> zs`yfwI=9bKd1Na{SVq8wwz$72vQyFB#Xhg1^{@@54X{Sn03`0wsn_*Ra89nb3wrFJ zu$YUO1X`S66LA4q$vVhNXxwOR*S5Uw$4cx+dKy@5zaCX&^NDNU%WM^9-wXE7uTeNY zY=BJ9PjHP=|ANxeU~b_txR(Fn9u}}>b(CUiFf&?+^k5DfU{jS}<`J@OgE)Xo#9zyI z3vzHp{RQ}6{B-DNaCvlVs4fP%-N?Dwl=|$kFNcZ?Y&{0I>`}mca1D0>kz7Xte^TcE z;wLHhfn8^rP{1x$tb7raeib7nTqW}*yPtSonQaY{osZDYLTgE(wu}i9c}FR&L%-~Y zq@$FfNba%Cfm5FguH~#Vw*hTg+3@kJkSiQCb!T_Q(Q>3p3&$Ld(*S&}#_N z7wR~-ke9h-?3!ez!OZipAz(z5@7;j@vx!u_j4>szsilQ=ISW-anwX^(#3p#qZB_T9 z_LW={j;zy->Ndq0l4-wi@J397#KRVS_GsGoHTW&qY%<6?Li_-mz$x)a*!9W+fVV@p zNK(i5)=ntCgJYOc6D55t3|-V4=|k(vC`rAlruO%a-V`bk(Pj0J4h>MOF+HY-Jwn2wIHE7=|QE9AOBOWr+-ILG6g^!3Z ztGmuSXLbUu=;}KuYLyr&z*q?vVN)FNjtUG_Lwd7xb(xrhT<{y$THLJY!)}FAG@9v6 z^L~`I6Nk$x>NTn9&Kxy^#70r3)xl-!FA+@+IO>E_8jai}j;H8;h@P#4hF#Ns9prk9 z2V)m>A|OS-UsYE^(YmXtiJfe)PA$B?rqGA=rY5r5hfmAyXTWb*Z3ea^PUlwtepWz zX1swCU`C)BdO&+SbP26@XSM}{3G^^S$_qF0N3ZqLpFFd&6lxgb83_}viSmNR&!`@7? zad8U4xp=AJw4Vm^Zk)~lmTQ*ijak!ShkMoNO_8y#EVHF}hbDAnhHlPM>G=H(q%Q`t zKMUZ~W{ur7UFn3}fjHkexc{M}$qdaW9mnl6^p1TjhwOecM?_B*A+mrU1*Dkf#IJAf z8jhd0EiqbF=u;w~6>Zw{*Z`4uRAXv;e@*)oHyVhDf@?iQ7h-@#Q z09FXDh&f&#f*X)SOhU}xYf)n1<4_7b!^fE{VJ$~NLfPnMf{i!&ay|IeE7h1%Sj@=l z@yq88LoWo=S)RihPYB{Wc)3)jd`kKz=Wa8J&>|^Ke2!Xz`8nyuF03}?Ceto#gAveq z4#tF?c}Humptpz?txXKaJMScy+2OU%S=CXT5x;ack{kR{IM5Q zY^Rb*ecEz0T1F{x42`q`y?!-iS;o#k5f z{S0Z;a)9zOU4*1vs~T;x4q!>{&x*clnzF0<0oB4?gS`ME?wrjYc}!6(p2S$nJiJ%3 zHPRbdUfeuv_Ye5M!8K(LzceBBjFQSOiOlI9Q;_d}NbD+gZeHZ$!){-@7lw#JB&+fBeQolpikFb(xC5Jn(n0VOS4=|GhQ_fIF z`ncZi4G87P5f-@p>?>9v@T7^0CmH@;4CTUTWGR&mO8ZdXtk6-TmM{Nf(ilZ`e{QGL zXqnEGP@BM@U3;7_tWA5I6cfymV$9jco(c0a1=(fkW-zQ`Ns79ho6_$c`O76@bB|H_ zmA9}`&-#sIvQh`#XWdTK;6kU0o0w9ba(kD@xWDpa16~t5J3Sr^jridUY4iVhcPVYO zb2ZTA#+(o}0HMh$NACxxNXf(74O+YQnwyRvY|@D^zZ~M**`(iFGh=+_efE!kHKZ*< z0cEl$e*2uC{rHSKT8oeCbKv^ebaK3YT}m7rM@;F z;L>pnq~J>tzulUr2ua;B*yA+fJUFX`zaP03;HY^@e{b|z zKD!xuiGx<^pLQ5LP2Urz>bj_kFVOAWQ`wOCU}=NwI2 z+Fl)S3_VTvqhA*O5s;aWh2p>?I@jNcnlAqyS|LK{t zhh@F4GU6DbG9%O3MxDMFWw@<9L~+*^GdlLuv&oktgL#|yi;z#1ZdC+Q*)UGS;UCb6 z$buh}qoZsm!g)-u*%E4W)nQN?kBU}e*e}qdKCQ~Ch+v}|O&nvR8|+VDB8*ji4vI>2 z;FfJL9GhOqFUqG7kib2CJ?K}y_66&G#siDJ=0dKi>3zoju7>gBv=az4o+o~ZqGHQt zBwN;(LwJng)7}LT1R`=*w+m*RL@T#ixa5vd%N6UGx?>AdW_viPeq%H&iaNXe;KC|UEXCAEn(W1nCMkG#g|3-K{S_)e6?(0_-m|04F7Dqqc|ST zMK0f4<54?GjoOJCU|3tf?Tz2fsRf>J*Bq~pBDB+Mz6Y%|>sTYUPzp5|2ffUW7$I#Q`d> zUwJP#v8#E>Gc<8ppSe-GSIl^rRHllN6!xfcWTwBHv}c|`tPFlyI-h?EW7b-Bxbn?_ zr<-3-g6HZ~AfD_DR7C*WUtlM_PPDdBh~{hknk`?0j(75bfGNXC@({*|=(iv5_FI_@ z#@*a89UXhm9!(9)=E4klp7OtI17ht~y4Lq8&H43I^Pl-1$aI#Dk6ybm)KG?Km00YE zR%I-lw+3~NAbvL@G*{W};enUzDIR)MlzFzyV4SPG=y;t)ZkUR%y_8!~BAEV)DYWn? zd!6_k>vS`TC;6LUAKzekn%ZEwp^c_jzd(6ngY?hVte1-_Y*n8otQ#byUEhXY^oCc8 zC7RgYi0KWp>d{lzMOz&6ODeB1yC<>eR+H0*q!sy84b99xj1w&cmHTpCcnM#ZNBY4x zUIT6vHT!83PJ%MNg`D@5|66xlH7eh40J8EXT~TTMaVygYmNP25e2V&9iMihG%GciG zFXMBuCr;f?yz3oV!+qV(bGix!#c5!V6`T})P$Yio>o>vrhUhfoICAj?dnQ@JjWg#K zd(~+4k`*J@IXo;e&x}rK7#rI!EJUYwV+-NuWgzS-9HtH@`nb6L+enqW&u4I?#U~~F zOWi}sD(zgO!yofoee24q3iFz_17~HJT|ZXN7nnH{zcJ;KaCMEV+NYjj-jZ|rG-8;_ zM^F1cc0&Lm5lF5o!b~y@0*!7kdr73J_SAl;GT!lqdAIxT1wpgM`1oh`@}H^0=SF{zN5Qr~P7^86`O}OEFXQ+o&ge+=LeNCO^O$-LZi& zdGCEMIl2L=h6Oi~sLg|oom*Nwp_bJ}edW(}b)rjGjt9n8rkr?JS{9v5;QFW z&jF^o>w^L*a&du><@Bblz&R}0l2nVQHrn4_IjRuw5vbAEG@yoVWEvs9%cS(6q5}x-2;2fY z0}Jv2?EBk=qaK8HfX6BexEGoXO($QcWf-H%8jOV`;Q8yPLe)pK!EVQ=@o1SWc`a%XBD z4(RBJtzFP$*0Y}!ty0AhmjON z^1*6jCp{HVZV&e-DIlStTuSjVyaOjC&Yi=8i)LJ;0rvY`N|m98*5#vaq`*30p;~wo zuCMAra&n|4joG>@s`Ggt{Cat24Xj(n5=3ea>*0+91q*Gl+9 z!jrpPBbF2G*dpNvU?T;(AO7(jVp;kH8zqjOa>@z7r6qO&L8q zXSbkK^9;p_@it?kOX(-!B2o~!`MT3v%g!CT)w(%rOn;ko!!X!tS;qs%1!ir3WfAVP z<-f8h_t}$QS#&UaCeFyL9a0<#TwXvboc8Yzf|fK)LrJD-9Ao^B1^z`uwZo(eE_$M& z{KjFFD5Yl4KlCv2yAZm|4gGAis`-{ z^VZSV0SfEyOt~4JW+3M)X;5x+yW#e({Z1<>3NsiGUzv+e=ZW08({w%D7;`EMzDv}2 z`}F2&I_6o;-TE^u>zlrJua?gfR*XLB!pG#1znag5oY;N{rs+qaHzrp#l$XQBri|_| z7SPr)4$Eqc2^BArSdYh9aJt{a8skCD z3beT04wNn=pa^{dc0lO@?ZbwM%O<>iV3_@Y1bXm{8mSE+1X~J(@RnL$KnVTx^2d3j zR`LUFMxW_t6RcLMJwjiD{U0bW9Pg_bC8wSbpIzwq8K2N+a}1p{5fvR+DTSwL-DwJ# z@X*&6x*afTeANl`8brb^gNC?kCtx-pX7Naf{^T{v!jT0SspxtFcb+*aTlLOa?auO{ zHF@Mf@DrlX+WIpgbsMd0_mEoJjt1?mhMXx`%ztp|$i ziL?l<-O4fu9RXq;V;V12tYr{-MMZF0%R9<@)Sbp}%a}Ox2Te0y6cU6YF1&&>YtOVG z48I+=Tb+~UIJc;Pw;^|@ec1RhF8o14_L5rgn+AWk4S)N_L^NO4(P zdv@a?-M;p0YmJ{Z+DhX}|LGS7x_v1jt=}hzI=F&d=LkKmi@~i^_zNq02pe5|HPGsb z9Nde36!~c^D1bG^n>>?9^3;H^Uq5%?+I5*cE7{SW*xCQ-JbgCfeOKd<9lG}Ih{ua3 zo~%DMDHO&e6o~8ZUZQV3(y4iWD@6%+#KZau)=x)%QMwnAAb`>!{k)S{&K5qSs7U;J zBszYF)!sf+D5T99QdhPqrDQ^nytyqZvHnm)V{1xKAe3=GXMbM9JxOMw&Z<*4*S4-m zQJ-b{$aNS$S-$x3*m&gpVTOxyZdLNBFXh;dLgiTo3umt8gZx6uJkI83p3AMpZ0{7B zx60r5H-$ryD~X&lTvi`%haY4bqJ|Rr?{7+kes(;~5?WSENS0SJbaD7pCG?1MKk`B7 z#nX>YyCs{Ro%;{Cwos%NOT9Z$i8!2gC4cJrolaX#W|A$H^x(4Yq82Z^jzlM4d4tb1 zne924Mh1^^L8)xe`hV^$x@)W!S zQ{1}SN2NeT;z{oL;B6PVqw2#@R{sR8jlrq% ziYtgQ68!|8I$T!=~hk7|^n9-va5)A{Yweste@LUa8?fViIBE09|fnie|oG*C&@ z9w-h4ZR8JnM4Uv2POC`wsofB^6X)X5r%o6mG$&5*;{7N0r+2siIQ-nwFn764&ePTfsoOkcHp;mz^AzWA^RfA0hJO=S-|VRWi~;l#sqz zYyv(jRB9^?=Q>+-=jyaiXo{sy_40wm9NWIe=xyLNT>cvaW%HW&F4sao)@LA_n!(oEj>5c zMpozROY$_$QAR0B^>)Z$sdBIs-p#v*u}7W`(`7E|bol%;XhZ3ZtM2(l$F^Szq6gJwZiHo%q-VpC+?qv+ ztEtN9MeWBw+MN2oZnK+wbh-S87G5}bYy$b(NB8M{9dSHDcU@U?e%H|(tfQ4QSVvT_ zj;uNNbyU7>cz5ZldvMV)>*tM#O2wM3*GSJ4vYQcD(Kdx>Ew0Enf6ZWxL@HyVDhP;&cO6IM9NJ> zgX-h2lPR?KUptP)5m`U3@{j{BObKc9EkpxL`c-Dbridlkd4bv8-qxLz-quGBVhV2Y z+Yg@pXqZj;zowj{1a=rq!0wbu+%5K!?nryNwe3Ja%7wHvDgJV*bCvISKZH!4Qwg$-EdUn&8i(kE&M3U=5e=x4YA z-_{gotws@PCwIJ}bwgyGV46TP)rQI(S_5}nNm;vH*Rkbd6;i}nAb?LEMvShjXy6%myrpyVJ~GDws(N)7^wlC$ItA~}p8 zQ6y&&0ZAe`XONt8&N*j>Jj3*DaG!nl+28rU|DOBZ`_w#Dy}GNrp?hYnTC3jmmSdYH zTPO=l6#oNDNt#JYWLp}E0*@oal$vTty@lN^gg>}6V{|Q{LC6--+2QkEI7zT5jcwg79XoXDBUP(z&f+Pr7o!Ujr30gV8*~cG z-bz+OW6l~58dT^Eo0C2B0LQGd{J^Ri$5@5}@2AcbmDIx$&b(%*%FWWI4h{A2d}SgzD93}mS(PR!Z{ zh9Jw4GnxX#c2{b3XK|BMGk3?XVTn9ZCs=-HRLp~yXMFS5HO!|9dLTZ9cNGGxCW))2 zR~_9I+^*d!nCUIBj1cz8%y+}zRa{B$CRHlu{az~l$RvYA&Auy~-`5Z>7nfKf>apee zz2JtWI^#%P&*ArP+}C<9XZ0Y4D~K zG^aeEg-%<(i*?2-T#pyvc|^&K9?#CYm>1JpOy*xn)7YKT)zc;_U1m|&3xwO!V9@0) zliYi)qQW2u>#owjq=n_G#Zk<~Ltg1!#psHdVvV_vHe^j;Tpc2_Ei6$0_pjYuT2oJ*2o|v&c-GR@%b;!Ae`6-XVuauC?!I+`+3BnE zbJHIZmuq+8N^n^$5Ans^gF9Ohk@qVZ5@3qT6g}EQ6Tt?iKLZAThQL);HKvc_G^4sDN&H8$7s?0VKW#qz1kbqJzWdOlaT-p>8-bd*xI zXhPfrC}9@I{w}IATw0zv|7AOHf81sIgGsu9m|0IQEr|H`q#|n>85x=9A%^lV-qFYW z53r;jnCpl(%XSgMk~mUXDj)AW+XzYj8gyzy+FN@cw6*wMicK&db_ut(%{sJ&P&`Gt z_E;oTjojELJFO2{9f8lSrQTbtuYR(I3k0Fh~38&i3Ir1(T7@+;&84&{8?z>R$N z+cgAlFn68$gbsT%<9dzbdg_7e=PH%X=Ji67Wf@Ii^6G@hfZVSk1A4JZ)vRQvJ! zn-OlPwn?>tMtgu`P)m}#cigAY?~-pJJyBldbn&aK4q=(xH*xkmmeiN z5J1Fvg&ELigG@~*8bT^0y?=+a&b1(yVq_!`Up4p6x|Pi>!+Su}Gd9Z?mrpqJI4awa zMv)-gHdeMLy{T)HO7XU{+Yi=G^<*b|N!Bct*F=xbLbG)_^qz6m-?=BZyWXYP<7O>L z6&@JX>D+yvYr_mri3Fd^Kr}r}CcRK5JwPTsOC~)|CcRQ7-A5)}HcCcCru(gaC2pUj z%u5OeTlci)lQ7&lNtrL8;E~pR5r(_hCR0O^-BJB}gXzE{rx{-OS0Mm*k6xxG=?4P| z?W{ti6Xf;crC7iz-k-wTfYUdi5ak3>8ae_yG9GyRY=&py?q&WKGMeFWxO>K+z;NJ^ z!P5gC|Ce2P&G1Uxy*2PSc=Ib^l2|g})Q)zx54@zx;-yHyDLU;eTclI{t3QQd{8*V9 zohJt#Da~+M+F2dO1EgZWX^c!w{oBRMblg3*C?{xESrvFTDC9>vLDQSzOkh8T1EfsA zsS4Onaq%(=Jk(jkcCpQR*in#RT__f9`5y!yF9^ZZ5QB+eb0sQ66+Yv zbh3ZIqQYGO=8gyE8V6^du^Uf4K6M|=8eVwkYu{}zMx(3oEf~g{_W3{%SlNB`aJhq1%>zd3nRr9cj>35=NqRxB`O`Jo|uz=Pd=$jbv@b$ zY#pOX#ze_^3qjt^0^Z%5(GTT#-o~eMM6+a-jc)$5dv`@?!PIxio=X-=22}iv^G0<% zy!TRQQ6lHAV8q=9iemB79=D zj%48R)Nk+%>{uM(Xw~s{>kRT?Y*rvh%XQDD!Qy+i@t%}^-z<*bjNQUh%S3n-x5Msl zYmuF84UGwMhgZ{>Yt3BrBXHf4^R^%Y*kVg~m&4PwU{=mq)y44}yZV0^fsxX35PxQ! znr;H2$kyJxA0fKXA7ub{sBI?)b%sJyu#$h3H30G*U;)ZW>6g7ye7W2eH9tvNe z?cIdIEXf;zwL&yL*1}zH z>Vi^yQcs)PjX2|P(Fr`$OKose)&I*zm3oV~t|^P=?dVY#B?8Y4?h9yLy)izw@EtJc zW3%bVZv%UXA_MXg8jBP#Yq%Ecv+%D(_REAncIXsu00HXeF#>MK7}*rckQ@)y%YqBN zfhsSa{BaIEfYN>pgq6_oNT)gpQr$@$kDbdMbAvRA;R_8x< ze`7)emb6xNowTs|A!qlMeq@r&VayCjdnPd?gNzv!31_l^#X?DxIAtApSH`!4v*w%OeV>kr3 zxCYE9jI3P9OA`TV(w;x%aO)Xg3oaYXU2^5|v?$SiC16e&zDVbp$Y#MC!M8L+XDLPP zp(LO*L-!M9^%;rQH|zS=>9u-Wvq2;3+)6I?W1aVPvB*JnkK628kEl1=y)T1zU5X3w zBaMD?L43;fp6iHQ7oJgmM|U-To{RkQh5F{w#g+IEjbGc=(buK1Dt~=-U06z!`2s(~ z@(rZPOysUhPpf)=ym;=pr_;k3W{jgLTgKz zH{qIJEk+f7pD!W*+?-t0+(Xrl;6C1UDXxt6@NuTvXu*mHID06SJBiAufi1D`6N1N$vl4Xm3k-TD)g_8z3)Bi_vRl*&!JCAV=}uIoazXJ z*a64a?_s|gV6<%MdKn+|Rm1#c6D>UFU9lpJjuLUIIJBSASG)2tkXt}WOS`S zv;lTWigG{^ZN*Yrnt9welyj_@h3@#;_ye(oYc$Ti1`2bgm5VxD!TsInKFs6Km@ak3 zzD(->#N$-9bL-4C&yd;GCf96QZ9F#!r2F~bx$|!P<2R*Pvi>Xc%ceKIh=w|wa76TH zRF6#Scp~mOjQ=<8yeR0-!=7BZ^VIO#lR^pm#vEeMo7dCwSuE~#`i;k-vo@x^GwM4v ztn2YkcCN+YGvzu2h36rkUi;; z&_Go_rf?5)Z8yTs8Lf_FDgR`cyJl>i-#40QFh%Enc>CBLzv?bb(J1)mZn_ji$I2 zXC9Os$*bj)iQFHiCW*eIrxN7^op(v1i^mmYs)u4}CRhw0UyS&5;wFPs|1DgQBF0O& zmEPg+X*YHE2WG7nEWLe@|xl`*+?U~%SxPlw(L)Gt9u~6^b>Z4mAnp&khvFkSW zqoA_K^TC_OC~8fWyFX-HA9EUO9npi;M~Sax*Oo!$?Za(-CsnT^-&TwBUjR2nn3wj* zm@m|NA2f;ZQ}Xf@oscuG#M8tI63CdT*O)_rk2`2>!L%A^P##iX>&?A4VX0>~G%w!q zlI6_2;=NnR{wlJ1o6Fj*)y2k@j9islL_G2-_cDWfxR+Sbyp3A5Qp7*K`S*jAe;{r& z$o>^^)4cJoh#N!kzannT@8OFyE3kFGScF#iH$tya^F=>8{BD%D0$(IkrMZ#DJn@vs zOfdC*?u|b{_Vc!F@LR?pB}TcojB-khJ#QI%lo(mwGHU&NTc!9egSacoNm%jSg;c;6 zHEmf)_tofysE9?i%o-#`5v+&zLn zofw~g3T3}QALh5y^Y8r{(B&x|sr~Kb{D1j1pv$AQdU^Bd?PnjvwFIn(+644ukp}O_ zzGx)s)BG+wQ>$Bs!dqZ*j2Szy} zT+18rTmDjYlkr6(>a!TFFcCq?!GLUpW1+&Yds`sMzSTKzglqG=!^($;hNz9$OUvu2 z3_o=45`xox(k?I5C>K$XLU80pq%hViae?8KrZ~v#>K~c}fG`aqK9L@bD1xg-R z(N;(rgSBG zq3mg$vK=+9K$9)C*!s#8<30bT-AbFdvR-$U%K9f;(~iTGA!4tzv|Q(y&96f^L8f!O zr`xV=daTBEWqFl7T6SQVr}hqnOLh`_ILh<*Tuo)HaT+jz*fClHN2)cg?E{C z9)J{!@j6R_Pn1e7@v;h=ZxLJX692&o@NzEj!VdQGqBxl;Z%6B_AMpzEn;W1=pRq^S z2uWh_Uby&uJ51*~g^c`$c^@Dc7xHtheieNgx|=qSAn1iEF)my+3ka1`{OhLj-!;yq zpJ6(DfKr})>smb+V`7)I9TcfsP_N$%ze`=c#!%P@=y43%cE@0mbcECs!HMe*|E?B``WqDJl+_Xsyu#KCPIi} zfIFt{HzC;QRKg6EvNF+w4h>8Qb~i)V-5Ab{T{zE%Z1_mi^nWh+8m#pH0Q1S6h>IfG z*?w~VY*iR30(5WZSJCmAQ^vZx2^|#qeffhi=Jv%qmmyWv# z!2#3l`pj6aXgi8E(XUeOn8!q}y!x)vtF*YjL$gPJ+4&!EiL46^oQL=WU(A)hQ~PQ8 z>C4Y{`zGRnP_FJte9+VebA$dL(YK9ML7(A-k+AV!q=~KVBJ)KKOFA->Zn|_qqb}3 z2g(A$qqoD^lLh^OkEH4%m&H(eHk=ETQ969?#K){POuja}96}wCaS3Df54L|WlHxDH z&o%HP{8c*2_s7mO;i)ph->|?V>)&CIpN*#Yfw^8UOZrvY!cB7Y<+w&u0_ec*Vr@T7 zzUwP-4WtCo@hgktkzG9Lb{!OYO-5|`U6fkxgGpKzRuI=nO4!xe%94-ogxeDDS$e%2-_O}r)jDM9%BB?F5u zOK_yf1D4aVz&gN9Z@mvm*(oXECyBz6o^}D_Hp0^{ZUE%)QzymS@ zpVaa5)cFGz{sA?c2C{mmRo5%n`nm=Dv#eEy$}ZvnKtg><_t37!+@~WQ$wf8n|RrXVvKsYbH|pMH-Y5+-}MuZP2@nOGq~1 zV~gHx8$hm1kZ5^V*dmmT)lUfn#hR~}=DN(41zD!i@)`K=Ja7It>|!`XS^+>kYCcw}FT+ z)w!%W9qiRHDn6M+tmxlIevKW3WXCh`siV0iHoAv2GkyG>tH^n5xf+o!y3?>j0l@0V z2=rRMy!1V6{~W(yW@sZ&MaVr;be=jjcmdE>6u_n1noyso{DTDS;VAdi9LpwO3u;J< z_e%efBM>HKPL|iYxJOcpf~BTtYy(HhW$n(R?rYfjZ5hrlsE`8Xx<7?-Y}fN@9f_+? zW*59B#8mob!&<-9{$`t*lz5|Gf|_j8Bu$he=);q=F!0wtniAHkJ_;(6K~+Bq;;nuueNY?Zh*AgH zo1_)~J`SpmK`}pxcoU8>P(=iQI|ow2u68aL5a095l|LvLF_>J!6W}wq|Bj6%nQJrr z6V1=gX88Wq=H`(CX6^Eq4rx+pr*mX|8+ipr6C^u8Cw#CBfW$Mm26iF=3ob2P5Lo@b z3=Zs}EIcLh??j)?{&a)*i(Tk#>HpDZ8(X^6WMT+CYQ!ZT@fQ{&;oitkG*VvwlaGOp z^y15NoZd+2_tD7}EFGaGzGwflE7C}vL@k>O$$xX7_Gq_nOkvuboU&VzIPqy3N?koq zoprXd3UOl#U?=|(+D+B_tHLSP6>J?YScU+dJ5GK4D2UnX26(Vo6EXF2oLrg4;ZTXG$~khs{GBOrI3 zAJi01xPnv$tK8?=MqqrP*s@ ztcNk#CiLgQeB0m9W5QsBdsl>0TZG%!vxshFX<7+5Ds9QoQnV%ONR6 z$CZK+jvMqBCWzLSH5g&;op6dDTNi!3C)t1L z11VShQWt`0@L}nn>794Y6Sb=_a!(riPEWhPx3>L)kxDSJ>E-8mmt(1g2Vt$8DHqID z;+JA1q%d_Fn{t|{RcL@*SG#c&7?6SV$4FzLw9*MIh;53a8vJSuD>}+Gg;(&HKP9`v z>kJK#DMPZrgBBTI$&|H{ei3z!@96HJI?2w;cx+QDfbp670dPgK^_Dl4|FOF^J}PMV zY0Aj74@_Ph9)}Ja;(T0Y2zBd`^+=ohKW4fzNv;N&8K^EDG-atSc?PXhx3xfuD}k6J z0Tp!^RKc&Q<;PdRl|NBF6sQKZ$$7nNq7%x!%U*im4ldkYMos^z4b7QADl?hXzMuEP zTEsNU2C|Aa_ye^4@_)dMclt09@SmozWm!f(-^Gq(V^TZ8I&W{nAg`0w6u4-*Vt>B8 zh$MH7Dnn4Dwm50a$8dbb$OJi6|3YRpg`l|Be0MMMt)BrFI7!NjMy^q~%foEZXwtbf z_Ncy}z!8&KZ;=f-4L4ojO;9U#sK~!(5P^OcvEtg4c|poj0P2PWSR?%1xk4)AeecSg;x_t*AfxT-BZ|RFExuHwQ)uYQYm9nlD0xuIwk; z7Te-U->0)L%g%H+$YRoZBUjnawTYx--N2C``>u#qrDW-k|7LAU~;gyePN5>=q{+~ls&Q?4#SrLLhJAASBY}6RVd5U{d zMu|Uc9-NR$k^7`caAMJfh~c&|zrdw=QPgovIliYUi@23j1)h0dIk8MMntfAucqOi+ z=?BPw#k1pl@$mLg?d&E0_E7E9@HO)ly*(`O#B!Qab!P~A)#9!*`-KWFtw|^vIa`kK z_L^l!LHlEgc(geS9 z5ILlHnKM2yE?96NAGpjg?quxr1GjZHi`bQdmcT%%a~aVJ~YrfA&aBQ%XYP=v0dTAsA~!r%yiw-J+hY}l9Ry{$3nx-ATW;B|}FCF{AeTgfl60c-cIHz*TPIR}1 zXYcu;6iwf{Ki!vS*AEO$T2m7(ykJ9jsMaNc6<%=}y5)V1oM=q|nJf2)XS+z$orA3~ z^JsBeQ67b>H4_n@cH3IrfmT&j5l?Hoisf17vxP(dgS4H$jsMqGf_kBvhAh4}ai!aD zIln_bhErM%uCVsFw`rMPY&*Je5@O2g%tc_@eY3tJZ0kOh=`~}`S-L((@}u zto^8by1x;bky?E2%kHRqrnujCWVD|NlK4xty=}tm=sY-hZN9V5^;uX?`|Vl?6fi~@ z@sTQvuKhHgB1WT{43?2{W7Iuj6MSk4{<6WJGd|$SVN0>$? z2EaF|fMwpu5zW>hv9Gz{q5@Ln#f5ZrL|d&d)ON2d9?ePn*xjKGC36$G2ZLK~kFVL{ zJh;uJ#qr3$om6w{iJ&c~^ zZQ)mbuIf&g-_F9TTv{(xiqe9y8cRxJv|bvE4%j!O&0o0qs)ayDO03LO9>A=SfCjx9G3IkygK1VSo?J8*BGeQQc1ayRxq2+rd|bx&^0 ze@h+fo9YeZah_;u??xJa$S7Z@VU~F!@9B}Ted6oZFvBV zktSfY&mbKV>OhN}{W8T7mzMB;_4XXKm7V1fZNig7jvVF3TH8*tV=6y+4saq=37Tr1Ul{_5q@>OfhedsR3p08sqtpYtz-J zgtXGQq`3t5iOmOu;CWW4tmAu`29{GiC0OJ5cH{(=P3700pi5q3~U2q+?&EYN; zp2OG;SXvvkI{vq`Yxa$TH&}8BlB8u@Zq*O*_I!y4opNm*F>Ye$e<4OdO&*Hg^-gLqBNSn!&74K1R)7#HO? z@frt6ye9Au;w8DO$ZRa6Fr@JQ%P{Q|oJgsL z7a}-$;!3_^e8LapGn?|;#2N-@C#N_yCw5l}2*kX4)GPwlXp29Yy2xv@r5Wt^%&zy; zs!|95MTgWl{%eyq=Dg7-Sl*=I9j=TH}q z3ua=17~r>;UTb~H+{f3cuch5vqqf#P*C<13MaBCdqUKNZ+3^Cs?oHWJFH< zKh_?jCcr8XJ$X;GvxQ@oocteSt4SR8bM*8-My9kwqQekPDq$5EILlP%Y7#~Lj5vsI zEu$e6te!y^?Gn)e7DpV3+agZwqU1#{ms1otl5#T^MJh}_Y8u3~i`N%9|4NmePo{hy zYDk6VZUl41kD8Lle3yb#6lzF=&f=AR%$daTChkX>nkqSqS{tcb(I-(M*k13egjFgF z=khes@diV`poXU?Mf)v`=lln>2U*o^hK#p7&R&u!`cFhk;v{^YPK~?l`FwDPO=9U- zcj5s9+6-whqDs9N!G%@%RZOS}!74(7$E#NNtGYMXX*I3=y&RUNCohR{+tZ^=i-snm zz#+ZroQGRxn3uXjZ0Lpb9u{j76yZ8-hD+IiFwnIdoF;S|+s`nyb!lfGQG7aFS@+vRGlb{Icat({$> z#IbtTyR7#skV&^Xs32ukEC>17n;1dtc zU@~R^m&qTZGQtA9>o zW};L<;6?u`ss~8?v3CjVQ~`Ff0y}}{{7uwX4R~nBDz9)62*~=Volezh?)W(GDFwim zNF19j zrLSq}1{0}d#RmBqW}c#*`1EIrMP;ZVpXP?D-QU~mcT7r@7}!SGK(fmkC%-QvN?SI* z<~yv>Y2Nh!%y<>{4Ld&ZuGq#)L}()L5tmZGaik=XxS03+q%hF!rxi<#srOlsziGAiF;1;7(r9SDQ zyyhWbfKOxk+FNaHH3_(cz@5|BQlpVy4b#^Jc$Ys%YjH1y4BR}9+yt!h6!ffbWU?WJ z+ACEqk=W(T(?iDE9!W(u^``yDT>hD+&VI*x9jR;?YWAMTr>-%dHG^j|wtm42euQcn zpF8sIi9#3W;NXjZ*trbW;279GE83jm#amP_G*lYBcyKefta6v!xNPiT+ zgrd`V$1k+4A*VFqME_Y4MH9QfQ)tN@7Dhk(o?G>`ZS#FAUr&tj14fRf>@@m4uw+s$ zNna8)Oox2&#usN^_+CnOneHYD)r%NcsEfO|GjI{qHmClnwJYU7l{rTPvZ~iV`{|+( zA$P%HWH&omyr1Rv22%J$=35gQ(476@v~P6k$PE5#1W>(QOcZE<`aF0PAbc=J^bih& z3y%UOi=U1m4k1F|mYaMIkYfF3Wv2*=_J3AN`oAtH)ahIP_bdLZ&MyDA&)9^jR5{#^c>&3f>qIn2vhwN!7cZa`ZWw3>#Ag;q?8q3aGm;K{JD1~?59)AD@twZDD z{O*bu*Gkh#>IT)$akPWoxP43)3rkD2*36eV_>18}TpXD7w$_@?*Ak9RBf0QuO0Oo5 zTSTQxP32l>JiKhx8KF|j@8Jz}%**Q=-wHyNY4yX#rsP%*g>5c%zK;k6e#%O_*`0-C zOB)B$6$$}aYB2U(g!dMd4xSYPfpvdYz0#mK`e&sabQ4~0lKAj{zgqtac3%I#e1egL4MS)_ zhuh&&=i9d-NIuUDFD@L6mq?GsQSSiZ%f!D(cK{KSxpW^S8%m5W5JM+S53kQDcOHV# z8U{Ldt8~q9doZiwo`of9%@l$oG>7idcWx|x)`trA4sm1`c=+lJc80*_^fKq-dliUf z-d7vSv&)l3>=#d0vZW$1TxK!uPb7|Goj-(C>q4JbMK9~oBg6eFYcVPdE@RI}cXg^& zq~nc@S@fd|&OB=sC~h5Ov)!t54(y=WykvaXP$!kR1{CFN9nD&?CABp$o9n{9x^m0W zMI7y>%`Lqmb!qLVC;cpHyB?MTzh9^+koUMNM6)x{ zbaX6rY^EId8{oSP0%4YOIp;%aCqJjcn4v2hknS0@Vzu^tuJXhJmF>1PXz#nWN0)Z^ zu8iwR#j4y|z*ot>j`&q(W#JKsabZ~s@>$?W*HNEZjzWp?=?|LNq%p5&0=2OW(L?0P zzMacZXkcD+xH)NfR(LoqZt-JJE=bDdVyha2vr=DQ$YQWAhdrF?{CDl2ho@ zBiLI?%Ql})P`rA&L9!!vjvKiHIKGH=0KN%Mc~oegg`e!T?Et;W5F7Lp=;G@VfcKzM z3=ZTY1}+XnF}DEY1$5t5DEK4>Y}D_QV`k=$*i6<;lsKoF!sv!pF*AA+WD|I!d8`>N zXJ5Gtzx*kEYrU*n`3<&0=Nv-d1y!J3;B;;Nx!FXAJDY1(WSuf&jcR95{W))!+Fo?3 zhL(M?;nby%(7ALWp;KJlp)V%N$2GvjzHd=iz2pXI2tZwQ1_{A9K+H9x^#M-Ewv>E_ zAB7w$e8cl8CSxzEu_o>N^M;1Mn2b52IFNA_)$mixJ}d+yTiz++Bp*+0_XR=_Wf_2v zkb3>WnT7^Th2yll{nX@A%(Lu$0?VGuQ!X?P8LuBKH_AN-rw=MxrU23Q zCEd}KE*HGDjzD z0*J%euv$_G4D2PgXw>Nk_ya~eGK|EwmPrca%O2Qg7+5B=)sL?@;a2<1Lmw{nfkD4pPU5`^(=_LVE%_)=D=SRNGhZIj`n;jVVdFRCDj zLQt{&Qt+2n9sZzd4&u{yT)WLC(|&r9c*Wm29Op!eH_#%BdF7l0^$)xu6M{tq2+x*1 zekyItT*kN!TJG09@1RI}NW2g8hH6N|OXgX7|F!MWI7p>tn+4;0rm0|sIbP=o;LdQ_ zL|CG>K3kFWVGN5ZyNwzDhqHv97$nyNP{X^XWn}YpX!D1V zY^n90+PFutt{zR^){%)Wn^%i1;j67EG_Y(=r_E0AW>U7Zef=J}xcH%Wtjcbycxr1# zupx#Pdp5G|^`?!Jnx+|k8CJeN-TwXyyW&O5~#=u{cq4=J!I$ zLH3M0%ft@$Ml;OC&K|i12?8X8tF?kLHD3DUt0CI5kWQ->GgG*2H-%$wvRdd_L}(8` zFM!wsD(hs}rsaxBN{`mM-7@eiFr6nsv7p0mis!3bZB zEV~o5v!umP7UJmG5UG}=(q%}Prd{Ijz$1({C^yATZ%DjZR2V9J$Lh4NiO!4g*{dy- zH6Y|Xb;nq7g&k|Y0)Vb#)QTYeWD5WtYKkKu6`=5o7amq6-~_*vg!VQac>2Q)3AT%R z!=A0u9GlN!X)HM<(0A#CoBPD+X|Gw8g*bWYT30#Wm@b)H2osE2x&`$Z&z+Fy=)=zI zW(&FBh5EeCbHOO87*1?@jE+&n-oO&_0-w;p^QuZAqr9Uwzc!4SDA`9k9N`@U4^PxFZTnyVI&`5?|sI>y+`XE)uAJ4aa)|J^(V{u_GEV%=e z?16*MBrQK&mxU9kQrrFRO?i)gWXK7qym)L$FD`twOXNuDTLWz0eI*1NS$c`Kjf6tA z)TOseY3qC=f(-lFpGlI_Vd2(GxDoP?kaWV^9u+@KGgRkw|lNtZ+(c<>uaH|h_Wry-Vxyd>K=w9T>}R$Nd_ z-B^70Yd_$E0;T*F(3I+a+PDN|TmE!VPg3c5P!FmM5ln6#I()yyS6>REKef$Qju8iX zHP{i;x6D85iFh}H6LeK!_*<9^%{@ON=NWZ=l&)D&*t_*a#jd1K9)|iJBPaX21)zYB z7?AJlc4*%5z85AzybZq*JPBwSv-9NuFVN(nA5Q=};b(B*GF*}wS4T(iChydP<)d5B zN<`HWHU2K??T)7y-ihwU>GCyF;~cKKwnc%@dx=)<2&RU_AFNV^88tNRno1i_z8^5E znz7c9Y4{?CINPdv9`xwI+sA^D`QBH{FWF*fQ>^B^6I{#p;QVU$8dBx;@(>}exR2|_ zI$Cr-J*kd(HrJ$%yA4!|bLvNoAeeNDepu3U*>Gcj&);(GT0+SiuXbBS2f<}P9t@i2 zQS8x)@s@;ni(;FbQsBWh^4G7@U~8t@$zX(QbYpK@vvep$LmgIRe#ue9w97BS4GfDi zmgo-^MKi^mj~Y;zDmQgkO}m?H1WJ!G*6z?!672DN?UDCDuiQF?JSSc{x zOV5v33*S1q#~NXX-)b_4x;>vkhN12=s^uaw85R?9cERl*&1CC6c=Ka7SuGA|f+w~; z7&;fMO$>%eYnzM0Y0;ZDyyWU@x2DhaiU{tq!neQc=-7;J9g~td*?%bYvw)t#7zGc_ zS8;_z+@T7=niY=qDu9o z+RV!yZJO&fvVHZVQvJ3l@-qg&#&JVN4Wf@rTX%x#vjc_|m8WD*-gzYSSe!(*-lTb9rr2W_C--@^LaT8H z`P|jqK1xWUh@^}&cz1S-{!Aq}&FdGlYe~AoQc5?orj3lRIxX%~*^p;6j$h-px+ZH~ z!~OaESEHJ@ad2F>(0SHuEB%!o<||QjGYX1@`Y}^Gua7fL{yzy42FU9E+Ylii7$U4W zHLlC?t$EVMa2+Si5V3SFHyMf2iq5YoY(ygE$-X%TE*}dl_{?ryW@0VS|(-oL)R;f;3 zMRvAq&C?rfxUMkaw7eSzVLp*dbS^p_E+#2KE` ztT#^7&b;)F*}x%o;ROL`AVD4d?gbz7-BTm;11=rn%ySX$o|quhI)7Ohtx_;P-@wzQ zf&(JSpgumU7q_?|E3dOqUc+)Hi7wOH`|5pZ79r*BiJyO|xSnGaovLmm*qw%3(aW-KNg03oRiK^ZTpLA{0N2mQBy^<9wPnIC@MZ?y?xd>=gl{uo4SEH{L3B$S`tMmFlBoGcKWm|V6l zS?}~f0xp*|%(sANxk6Dk15_V3sAA!{g$qe)G|EHPec&pJ`dsLUlQW==9fOLz za@m&M-13}4bschHYtELc0m0aHnqHKhcOU&yS-Wu+FiMJ_-Ihso5!OWl;_L~9Qp7iG zz5_8!_y_>SFBzy0>r2=N^ikNsC&_X9hF{%m%DS_7ACrsr0Bmyj%OBe;soLD*mQ3X< zLG?z&&@L`yxbrdD(4C-qJz{7B7t(`6rooNV!4mNn{&50Gy$%m_-?W)VDv(;M765N1 zCE-xKBwjsW?%{#N5(Io*CWy8a6In@(3-bcoWEL{Z4HH?mIGn3SQiK1Co7uOnDtVHK zvI51)RvA+gCi^MXEZ2AGCO95LNt-8gYX+O>4U364o!~7a%u>-Qz}In*0`z60RuU{1 z=PdRzhObxmRRMqncSqZ)mvfV!gqBxZM50#s?l>~7p4>;tJT^g{;%>VPHDb)i>J*zJ zB|BtTb$pR{vqd7jEq~4$Uaq!f7+VljYQFG_bTT>%HlKiqrNyQyEOkS6S@1A@&EoM@ zsJz4rpyZ(OZ4M>G6@4d#@uyII<6Y*W^ZtjR|8nGCy8TTcP{7b?`pZ@RcKF{MIoS+I zF(>@?`6~4(^sD7DsGAOEuQP*BG9n^J<*ptacVF0bzG~hxc>V&xHz9Z@+<*44H3> z^GG9$P|;V>5Arw(m+NPA3D@d1=IYsN-N-rd$&f~{bO>fMtU9>ZPyZ49MBst#(6`f- zwuG(h4;-0ebYL8~c?v{Z0MsNQ-yfM)oSFvFz!`Q3u8yx6os@bSPybpOv&yjiF4cW)8~d>8Tix_B>5k z8@pd!DIjb;+hy0m#J8%4bc#I@RIvVs-*dTY{9;i-gl7wknAZdJb0=<7wL)b7jo%|V z^%uXVMCT&XCq;~55)m(W@viF0*ypkc3ynbvM)3`M&%0`Q8kZM)Z>orCaujSCauhh~ z(baDrPq3jLPhhJJ-pQ!Xk>R*BT#=-D(ATEBgRC5dzPUUx!xx-(t3RJ}e3hgY>Yq(> z7}MbuxV%ZJ8>?Ya_tmv}Hu=!F~Lx2;(X281%Vs zhc@;U$ITI{eG7cO$#doR?F2Ub6#E3udQv{6tf|gD)6Et%@=nnneI2 z!V`rIkK!tu22X5N&k765s;AW`iX7VVW|aOByPCU-T_udQR?8bxYuou1Z$A}qeW}RZ zGAqbV(%S1J?07u1+8H!*eAd#J5XhOmygFFx*mUw*+xJCP#twJ=q2yK@_Et(o(0hlZz9Vi-7ANmsS8w)(2UowS(IO}bEzb4t`ggO<@}Mae^5vXU zU^V~TBn!VWTHT+*%%jr-_NMFsCXb0nrylH0aD38xgwc0yjnL<5u9|mhYi&gOvhXRq z7Q8cj@Q7>e2<813yy+w_fP?@9J*y?q1 z15$7R9oYQV(s1t4(<3X8pljjRji(2ztv+#YsP24k`Y!36UETTMdnx3E6`wo> z{-}mF(LjB`@c>=Gf19CR=c16!spj4deqvzh1a9!kA2|3b+zN0VG@Ma<0XmR%Ilu5p z&Guh?;TQR<3Iui0b}3HNy3pU%BWnPm+5X>knHFy08)7#~T(k&XyL+awx8e6AbT!sr z^oxjo#QzB^f5ljzzP*UA>i4rY z-hF5Lql!QCYzMUNU~8tW0HfPOG;6KW@4Wuo7=0`-)x0*c23YyfuTCw#BK@pZ z_vhaf?Ld`GUGvnl6AH*s@LLqvxS zBu6G^fSMWDZc z0q>9|@QJ0G7&IqApfAPy;RTdmlmG_ApJ^H)=jhJQ?CEX_Cnc5nh}u2@ZEwECkHTjK zd=HDiuuWEjMrynTr>TJZ_nUHsHSa5ZY{q!Ui*d)2>F%NXx&i_&Vk$NBT6(WI*}pZj z_PA+2xb1cSS@-WTM}uL8>nw!`_>*dlf$J0A!NCr1T?;buoDja&uRqm(YHoJoC&%5Mk(}p)`B?4=<&rn z?9S%PA7?;dN`a?vbXkgQZ*FAtS>rcEtKqSZPrZpt@72)D#4{hcOCR_+K5#WDqaLjz zQc~Im)apDgQ4+f$S`%6|WV#)O12BR9aX>RuFIbayI#fB<)PH@DM zefpz1dkufS20V<1CUycT-Yz}}iazQ5HUz~bMK>^USG*f&xEg3KzJmD@CnS1-kCTr1 zpF;pbK5M;s!8^VBR~0V@8(8sNRoqNh`)cx@aoA|{a9kg6jwIyEHxnf@XMLy zO|XW^w*mXf*rSTuKVzdl9_aBys>fLl+K0|^X_)4}A`4j;zqa41GV=&-g&w2jxe@{^ zxPJZUA;7J>IiJ+#5BdySp_iSjH$$rMTRp)+%V#hKpFn74(T{x4kgF*TkzHIzi=8U?{)gPirb{&*Bm+ne!-j27Fclq7iVuB zmq)Or3FB_To!~CP-Sx#axVyW%6CeSC6Wj?9G{G&nySpX0yKR%)J3G5`=gzm^KTrMY z)Z5in)eU{>IZ{9UgpI8URU9olO+7TCe`;kRJdaA{%R+Rz9L(RH-&$)ipfr+<>f?{= zlNLN#LRbmbJeAL1=XYkQ{}edbe#E*HnR+~5ZZ9fg9_q2Ne@^()QUsQ`v&+HJ&K^}- zIv_h`n+~CrY`DC9Jj6zkk^Z7NYp99~O4!RtZ=;xNF5MeS$RXJ3$ZxEX&*jb%S^UDl zFhF64Agq;VX7VYor9)hGDg`Iq5+R7+qq%g!tg$t=^@FU7-KW0UT;NokW-SV1AzPb} zm*3QR>SwF?7PX%d$DV?;R=%-(&G;U7yzIO0bYa!u@Rl!|eT=WbYnODy1=T9cPrIZ} zIKzU(JJD}dUxyC_3*#a6v&|i+*=>Fvgt;74!l+G zR>YQn@X#R5Xu`9^KtByynLpw3}#+P5|v?lNY*Hr zh;bE)a66*~vEtNQ2VWpOx>_9WRoIyzZdP%h+?Qnu+lPlGI}FHghiQ?OaU zPy1O^uNIYwSk!77JG}mTv<+aF9CbYz}?G93cqpu?L9iY zW9>*Li1{>S=$ovN@dlIdTdnY*s@jwCOUyU$eCDVO3ht$ zL3?sK*o$&M9_(akQ>&v$0`#~_>c{|7r+gS?P_6uEJdN;@*%4JnIKPnrH9e{4H0^NG zi<4s2b_z}7t&Mgf?JA;!rfkZS&<{&<+nV%%9a-%9SW~>pU6D&SKL;=04lJLPG2e}) zgM&3U|5`x@2uXMI{X-ld`2iHDW+Ao1+WyDMuSfmuV+<{Kmj3XU^{zWcW0T>O!wIj4f9hcxj%l{tuqsMKtNz$T(0i%(KK<7b zLf^W`+s(n1q)JXs2I3KKj&H7Imb%|3p~&j(?qL;{tF?Wf%pMFV8Th@x_U+Sz$9DXL zNA`C-zaP{N(Gy;}qZS|JvJxa=*8E)A8V)OmaAz$2TM$J_)tx?U@4IY=bDwV?PkkR> zIqZjyUat9eLA{Wo=q@;zQ`nb(!^14HQ8tbD(}>NLYP* z7o#Oo!=n(Om#tQw`n0AVXC5Xys&}`eZN+rYk8CiHDNh^H*nFJI!rdh9qIsiO_{b`^$@XHBhDOJ=L_r$&Ifh`|qNl&(zetFJzFNj}53REx5<`@> z8W(p|P^svM+VOQo2N`qLE~xGo%LPyGCt8qZU?O8T8zVdbzH&yO-u)T^_x?%+J@*QR z$w6YF(s98=>o7zG8J@4=;b!o$5q~`8tQ!8|DHnViiRL>{UH0yKI9&O?p~gn;A{nYz zNmZt{Ck+}-rx4~{A8SIuEggCaX|S#->5w$GHf8Wy?QN>40~*0qd@KXT!fzAag|O4O z2!6mbPAuD4P)EKe%y9y&v7a?Q+TZNr?U9R-AS`lx2m=PsZ?=rJT4`Mf#Mrnn268RS zHw+cocQJYp7_sqD4CHCjx0__RkvXyVJH5C)B0lvUOo8#^N>9!vhPYO{h*ZHoFfJW0 zhE?KaM|+7I^3s{5>>Pw1`sQ`veBD0iP7yCT z$x1DSyWKExlO{D+C6)lpW{MWqT=cyJt+yg`A!cF)gsv{!=Lgn27gKVf&gfUZBh7Ht za@+j38*~+Yx9B9*x)m<6s1Q!}oX-#ca?yTxEUhhe>8QzR z&A*B=Cgh_CP-FvxFV12VTz&y@>s_dPT(j*7XYEHUL@~6d6TBeL2g4LIlI2>J{krQn z--+L`|8n9==1)FtC&}x&VJUjfcRPv9**a@J;o1?~_hO#n^eDqK zBaZbiNDVx^Ii4B4oGelQp^8c6X z=FKp~=2-qu(&WFt$Dfu~Z{uR!pFROa0lEg1sCxlF!p3V!91z?lvTa{zrN1YwPX(ML ziLb>oFvqqvo3=KDoOssRM<&$sEWF&0wxf3a^2qG|gwAK=#X zZ7|a-uKND?MQ=;j09ad>+);Re;xk&AIV+P%Yz@$oCas-Gp*_G_DVEoX8|t;zA>8>h zf}dydigH>^43BaUX?b{NoKj~xhR8nw39{XaXO+p+XXXI%IchQ-wL!YP~&hVm&Z2LnYw*;Y&S3r>ya3^ zi;kY+F*q2lz{;3w_6lZ}C2pbpM2`InC&eA6=KqhuxEXTxKMcmGXiML{z7`(!y$e`& zgUW!GSv+=|ZM^#x@G|D_(~_br8!&pPrt^C_j$XxmCPqk7s^sOwTo)k#lHcQ2OKhz5 z{3f8m%qUg*!BAcc7nGPc^6ihE*tx=zZ{WMte6C8Oeu{g`$`yvriyKHa1nNpFF?jKk z!3O-Z!AGkVoC?CJ<7^wo+Rolchi%9O;K1Qwa|SxjK>o#-3G&1^(U(4Or>Ykd48Wnu zQ{knrz5G!cY776q;4nF(fCq4o}tuXe9-E(?&!l=`tTeYQ1o1uhepNg3S$H1=oa#@_y6!G`@c6vuPahLMCDF* zfc*bRak`&%D>R1RJh$`)mn>v%E4N|~lDcPdQrQ_5*2N}Ga%$`YjvF`K zpz4|;FHOuKU{(# zdX4;9?{=Yg{{1h{C#g@KmpRY*ODD~vVRHctwB>uzYNY8z*mH6kve?wYeYSe~7eWbN zFQccy(D+#%4B3YVYsc!PNet-Ceomr;E_Z@Usw?AqMs#Om>3mf5wo4un{?rf z8c@md5v9z6OrJB8o{u6r`D}|9}MN5Y**^PT+kByzO0$LV2~1VFFsFnZ*Nyxe%asCi)isp ziu@H<=q=N~74T#8Zrea$vR*pdm2uzmMWZF!Z<641$^$rZES+#Zqa zyGW=sgxWZN2PNO|&KwR>?d6XMmOtPYmBEfK?}^8c65sL-hD={H&BnPh&c%b(uEa07 zd>A9a<-}ql^bzBBqU$W`3hy~etTi%-LVg&xl_ttrq7^B++&%PCI+qNVlNreqzyX0| zfvyo+wZF-uKGyGbo+Iqtz)gRY&M9S)4VWenDcwf5q#IUgF>yA>n>J1c=dMKxOe^FH z-^7Avr3`D4%3ZZXH>E3A;iv4^GAp^XC%}KO|NK_fsIz*CK(uw4Vmy(Z!D60VDZLb! zEe`fh$rnT^kS;fCG9qQaTEqAOa&C7lRNADG%k%~NY8@34#+Eko$h%uv7o;J2`}VmA zZyQh!?K{N1n_i`5vd-D=v^Z`MS+sOaeRw|u9y^vKp_c&8;3Lz|qVA}bT(;kDSX`K^ z;Qe)z@S^b9KysZ$EaX*g0WABCN)dc`oRktD=ZQTEzw9BLvBZ zMt6~y4jUBMj3#N=qB$4g*p(zvf@tBF6r{S*(^J)9oe zZ`KY_MKR#bG?rtKsziZ$m`M`G0wmBu9x7;-%wbC|owzXbitlheTi?vz*XKY8|bVq~R|Jyh}nBdEEJC;V8u0M6hNYI;nxOMx2tyV`yL%H3+YDOGc7K zRFU`~5%J@UY`U^!55jI#Z*Tn(4k1pxQKq9oF!BS(yvJ8=#x`WvF|$agb7&i=wk6d1 z4q$~vLshgyhhMS~%@BZVLrta`t}vHXPJ&wx0wJGd#=w2D!It(SpqRR~!zNm3VD7IK%i<1WZVzE zgi#`P!22%Q{a)ZRZx~mps^w?iJ>*V(x(=$V>ZuD?V;gbTQi&JU)6)Q!&$USD3A;1$ zdNe4hkzn6voAoTVf%W;OVi@AL^EptP7z|nw3OD%7L{F*%-k%WSoUDr65GmDtHGw>^ zj0d8m@go=>yTno?ER|c%6q2REN$k|chqkuG#GS^rK{~n2O7ZY)PC*Y5nn`2DvG1=6 zWf2O7>D3R6tF=fenC+_P3_jNg&6knedxwL`_yK2~R(g8X)a_MXN9eKdGk63voyi(d z6-q(!x50X9(42wkP7fb?JCAF%-X0Es;D|FvAa5A7lUC2oJq)-oQ)Ab}v}(?d5|_VL zd;#-$7^)eF_N+ng;KAj|Jl{q1Imh_9e>6beKtEmo6`7E`@M9wGnW7zh&>Fg7_7C>t zM$x^7dwl-Gj|~=r6qx=}fqsLo3`2zNfe3bHX_>{}yTgDVp3v>_ZW&#{_LKyhnb~is zHcv*ZVOhfN&hN#J(XFc!@Kk-SVlX|P&hk9(Aa}CM-(ks6bkSe9y-cSaV_iB7=eik(ThJMZ|8yd5NgvY0p74F6t}6MNiYV+*tGr z*PrjT^(h$1&CB&e-TKQ>b7OLLs{8P4c@n_lkyPS>62O zsXDFrG`=(1WL1|{P%vTE5F>l8Z_^x;A4v&sQq&kL)yHV&uqxDe_96gdYy=xGuyZ3T zbPfFpoG4K0DZuqNa%S+$n9KM}#B)0b*5ym?3+hwA?-$idd8hCf)KOtXKeFFL2-CWO zkZ2UpnD#n>sE|+bSOM>^RR#WQ+TR3uo=t-niY2kf}bnSsKm-9kSMSo0x!n- zB~z!p*Z0OC^PWTU?s>Grz@+Wuo8%MzPyT@I^jO7iQA{a!Q|P?2XK32 z*RqlpnI#GC=ig~`coS~h5!a6c56ODsU6xw4pVQ&<1AQQg?&$5*oZi^|+G~V{cO`nx zefT+~BdRM-a_#bU;SDlYZbx(BT#1F$G|EfO?7STmD>|}7ZjMjF9FiG@wMBpy>ON=L z;v3{bKo&4pW+OHI@wrBS%uek)IX=qEIOBJlneS}nX;+P%?cyQOU`<=D6{iM2dHJ$+ zHa=RHphK?)G%CdMm6?Ob)i2rQ4-(BNQ6n^sI92d^s7)-O`ra}hRJ%DF)ZIPg5@Pnr?!``6UP8S0u2Nr&44?a=`k!AN?R~esnUV#hZ97wlYzC(q^90_Xb80+A zs1rgPW6>9B93o1{=`BEBzFT?&N$M9UQwlD7rUfNS{_bhmg372#58zocdR4L)96&M z)O(sIyFbUpWhkG5(KA3W-ssovH|4kU!-Lbb)worajyH1GJFrpdBcc1)W z0bS5niiSpq8TtWHv=CLa2QrrxESD83mlYzHbyT%6aQK#usQN+hyxm`64S$+y)RMpC zVb<}6_%jaaiLRM5v2cI0&!_QKB=S?d$+^hW{4|p68#1Sxtm-imyZwer55f2g1B&BS zjH2^bX$^@dLwWY&OiT}(5P!Mr`;b}9$<7?niI9DV&$@L%T}!4y=HP)Jx|RysZ)m`j zlsz*pZ@7v63i=Mf)G$}6o&-?ZOS;}zNKzAue>Al~GY0;X!U?>tGSVbDoH9?arw{7z z`iIL6xUx37mR8u`r~x(bcnM7wC=MrgJQwO4M*@O$-w+&5-rPq~&pud=iemAac(ure zIg`w+VK$!PhjI1z$Dx2>0j1%mGw>3c7Frme7!+ZN4f%KTCT?JK2o4`Qi9cr>A8mM* z3qTnik;2)aifH=4buEga%@PM!U89B8N4)*hWW+d2oY}WX4##`a-b`uu0<2zl8~xv+|c?y;1Zz8iE);*0%Ec;N!N6B zkL}Yp(=~w#e}78KW2*%j4ZpiGYVDenT)_)zSz+C2s8K2T>&)q2+13MX*-iJ=?(KQ% zS98*Bp^05S+d1f1P)+nCZQ=CIm6{% zs9py-L&83&UKcq-U7+xgGyDP+K0sSvLDq|Bd%rd^lZR2ma@dA5PWX@>?tiUR^zB&O z+rr08C3kq*!N)8mciaNS7ErT?k2wHpKyi^geX)9s^Zd1QhZrfvheh|e)3Fiu=YOqS zH9X3FqYHBSijLb36y@l+V?a@Xj=KgFmFT!9K=CcX`&r$&C?u)FQxiTWI;rCpC_V!< zE%=xWpavBAKuudl;5|Lh)Evn0O9fQ#63B2FDAquRgq2XeTOdPSp!fx}RTgB;C4Jd* zr)2Se53%J*$&v*W-jpnDK;cWtvI-Rblq}EhRmQ-Z4g9|Iru-UeEi2ICd8?~HgvL~i z0+*r+8#+`9*|jqbzTvzd=*Z(H-0+3>bs}rjVP}3Sxc_}I>D{L2ZQskFG{~Sd%AhpK zpft;%w8)^e%AmB#ptQ@Nv~Mf0R&2ykwAbhsV|G{Tm*E*w&8Cr)q?kjbAHRXM2Py73 zkpQ*0fra;TEJq_EhRO6SMcfFD?@u z=9kHi5wi83Zc>F7{n@9KVV^ahJT@muolpkY@EZ4P!(FWtj?@fq9ZAl@AO11_aEYE#ItWvgFd_)yeZ zRgu3FmLQatW&o%Js^N{`Mn2`t<3@ghM#W~ItASICN&q*wOkKqW2aLtW{=DDh%H)a43KhC|2@AW+y@3RrDr zG21O~P3{&}R_d-#&*BA5eUwzvAc1GX+A$z!z?yl5o>%z%Nm7#zn40|4%+9JJ{x9etb(oJzj((JU&ecx3&D#InoQ|dr{ z`u+#)9rWg;fs&H!B-uj^s(j~|GF{^zz_3l!{x%Ti)1}N;#7OSo&cO0YUIl$r_RBF* zYhQgg$Y-$Z+1E2t#8a!gIE~%cQDL3C434fz$2^ljERI&C2ITWGDs55W=u+VkwF)hC z4xNOP-GM87lRWqaM9z>zo(N1piYB%y@uO1ZlG_k|b5J0g+B|(`4lr0OV6dKZYF03P z>gj)jfWh|ILpG6BjIKM;EVn2cs|CI3B8DI{&TOc9|;k#mcMwY~wx zBE81|jhy-xntO_tMwtg0VC$+rJR?QlNYSp((9&MOg3HlPs*^Aja=-VhO3c%}>QotY z7>AXmZ5088hcJLJf#4Dl+PwyL18(X2Z$y(aFoEZYexueXV`RokcjogCLHqmvERf7c zZ%?rhqTz(uG%37o<SVjAl*bxOdy?~n$P<3EA_9`j+vKmrxik?7 ztfRkd5XZ(7mAJ?dkMiIy4kzF zCMMuJC6uqqEr$vDflgi^oy|}qQ6H}-X zQwB9X<_e!jKn#!|Pz2D#{p$*408Qd!uF`o#R)Wyn`&R;=Yt6s_J^;NYmrpyftSN;fPW5BKH{V(K#cM)rGuXwVbwBks-Qpztr1l2)!h-o*6Y@!K*Y*2g>dupL z6X;qFHbt+7rcbs&WG3Yu*G4WhIrcUG?pIfF!fI|Gut_UT-Fv_Ln&Fqs=FOcI?K)ju zYFVMC#y0I2<|hY~pD+%?HDv5M7R(svn4?fgiQ1shi4ij-XtTFx5;&3# z?((m;U_Ybz4PSSN%aQnY%86ciyio&(1ONH$%DFnlrbYf+ z=q?)m>@cHG%hXwLLUs1YKEDB{hCnGQ(tFH{1#7y*!j3lN4dE!`TH*Q6Nof3{>iZDb z>JiPyT6J_gOx?kne-*Et^Xl!xL><1#t6pO(B5p;(#`K@6(Q0)#N?wECHWf8JPc1%h zU8ca(OAbkUceUIaARzoq`DC#ALIDJ%ZqY|gf!kBhoqQB1(U$H&-sx4^X$T@1-0Lnx zcPzw60Tomz=+ec1=XEL-U)%?TR$u=QAy&r$iX?eQjMeXCXKe?VO#RbH@NI(}O z$|Y(fPVllCVy{N<670(tUX6I}ZioE}g8dp82d)XlR(=njY>fQX&ob0ZyaHQuMU0$* zoUXpZni$^*Pq`Rc<76CeD3Xq`L%T1|wKN~&OKul&gSe~=TB#_~9{dg|ivKS|O1oSF zjwU5|lkjUR_B#3eTs?ebyGcvSPm*Ff?D&hh6XP(k;nx#LY2|AL8YUsUR`ad7}LczBqqWEQ1=ScuWtOA~Ck-+S<`4z+1DjB&WcXP)K+h?EgRV)XNe*ZURg(SPr7G`c zojjm+2jhYOg^9~JM#)mjH;>>9mGn~xULqw#18Oy_`qJDuXLqa%H;Ue9TB_CNjGW`Z{ z2hE|D7jQo01`<42hnfLy-pjM^$r&)}VAqS<@Qu=P1i*e=&cF>p9f3FHGULS`qm?5g zxr!NNHl!<&U^eY$S*AY#Awa%WG@$j5jnhF0L16Z&h=tQ&H$yXUvJyVy{}U+t;JU%0 zs5J?>X6t5pAZsiS)x(CF*HL>TJ2~x~STvi@UjMeR6pL1#QO3$z9;6zrlCGVE-o!xZ1gi&I4jug3S3BNPsHhdl zIgfLP`S4G!oSa1X_5H+Da90q96y2()vTe5@)RurPa6t=zvug{&$^hR|(BFJDYVN`1pi z@45quZHTE-7k*zk9XSN21O2ZAV+SaDCG7CDysAC^Y()rRO-bA08-uSpRmod92)!TZ za*EJQy6{DX5I`?cvIC&pHz^76qU21J1Ywf8ASwjlZ;}BM--#BFhJ90|M)7yz*AE-1 zj#@7&u07K&Zuu4*9z@ghIxFsKw;$ zJ5b0E$cAo8&GR^5)`N78v9Fy}DTBRloQ@iTBY<|y{HLJlufVlJmiJf%mSG68X|`~7 zX*M!eG}N>~n3>{e;rA^ChNE=r2`Pq;D7qIa)Jzy4^C3X{V*}Lk(7Xje z`%@bMboE@@?|}9PB;aU}qxr&^^wl~k{9Z35IBZx1r+wOcExjaD9&^wj0pp6fy^5No z>;eY7(w`>~F$;_-P^N<{EKt@Ec>bp81kQ#vX3<0VDV~SaTRfR)ljG_H$ zChi=OTlfwzfJh&+)NvO98sGp?cyeDNpaC}i9EV5)G{6EmBuPC89BvEiSUK7%sv5o$ z(RexUJn7xFU{B(X9ut*y{4}wW20}a*j4zhJgu4}h9SRyBDWDD?h61y$#`u>)py%E7 zV|3JgI9dsdvUvvz_h34;{sL+`)8`dMY!P-G!-YJ|21%+^Wf_=ZxbF~^@vqN%5y~-E z4tQ?kRDm|8z}pbY39d`7O3g%G2mc#CZA&>NP{J7Wy5Y%~puz;1PGV59zWmXsz*AX) zUw#&r*zOO+>r@}dV&&Lm6-^7Q{8aiT<;@_JtD79~N1#NNV`E|sbju2^hd1TYELL^? z`Oga~KiGYhlzO^wF>*8azAHQQaQEQ^|BfL9mg#VymXWxT=%TOtI$!I$FEQfl1I=C( zbuM?r7I%pMT7usR8msGo;|o_G5qC)LRc1c7L*W?R8~?}5eEyWre&s7K79wPo3H=6C zh)fAzBLW3#<`o((iDB7%dJ`OinUD6!c5e>pv>3I%z!BH#*O*SN22i};!;$3#;^1ph zC4ZLlKj>O4n*&ZwR6yo4@e4JqVQv+MR3RT_S1wd}{UHJ09Al#~lQ!)|RbnZI(dm^$ zIKaQ-S9|KICsiYb5c{jGg89=@Vli(1mk$@`rh3Kg%en>?FDcJlu+}4k?!yV|9^BJWT(|~RJ9LZo%1?iCX&4v5IGij`^fR&hAGmN;-wR?M-;i*>a9&{~gRPz{ zI|fjCa!w+c+xQX$lcABysu80N-0aD`d+|8_kP=eZEAOQvTk=OJhV}=cAaZDt#qs=uO<^~Rr49WE*?Hm0Z^}#Eun*zLW zN2k;b{I_g91ouOsSblN|zvoiW;)1(QceYY)gk&7N{7$eP{RhFy_~{+J5!CuI9Nj+v zU#P@Qh*9|oxS>!{-HtR`;2WkCj>yd{0esXxIEq*lqIpacaVwExk89ELo@kR}HC*RO zPX)%vJ>@)o)IE`Q%SONG)o>f=3|H5q$o(#*mZF0lHIEvUh_mjBi&K6dod#YWPW2Nv z9nr*+E+#jPC7h}YHyzf*5faW^z)vWs?ytH- zMRk5*=w$|=On#KnrTL0M9P{PWa61&1oSGk(XQQ(p9#vAolf}6!B>q2|D5&}0@@&j} z#y}X+R=l)+`V(7J#6ShZVr&dX{_hK6;3pJRFt7K8?fs05yTMBZexna{kpkhNAIh2w z$)!IHC}RP%j!P1N_H&v7!KL>P!O&lUD3>nORf^~jo$08r@ByWwwLKWE*ov4)<&}uB zh6=dksTjR*yP@xbd5mKIo9ge-cM!ZrG5eV;y?v^xbv+)S^cKRXlgIpQxW-4%ylMPr4#0LDqG-XbJPU4?<@keun4Smn12e|{|ZibZMK;9#M}mg?OTg3ia4A! zNk8&8P0e7e$SAPeJamY@Ongey-72a4u=7h<8TrChP3>H5ngD+8XU` zsYqY}40yu7tmG8H1SQ}X6iq8xwu>qhB={ODC-O(23hKIOLM#VC4|kJ7ng=h^0|ZmN z7(g)5M*!a&a}E`nlB^9T6P#fl^qj8j6N78EmHqJ*Qg}A`wqEP{jmZ$({im^pR3{p&E)# zAX#{)FCiViMx0bE`HOV?(`%T%Dh|FdUJVqD1aM_PdJcsaL?WeZ;G`+MQWkJ!BBgK| z(5MbvIf!0js-?oFlT1@Elz?$p^N(y=Lxqxl(Dq7d>oHX>(vEn!r>_*suQ-5086^cM zluR*zLg9-5C=_=TV6umV0EIHo11vz5OaFEmk2?z9ABE2yB`nd~nXLF3hdBCgT7v1J z=N${x$;EKtzBtnHccyTzNO%xLV*jc-4Yax;;mz`g(@@MLU})8+eZx|42iuuN&)L?7 zNYn$SV*{@Q_&S5CD24I9L(%jAbDam~ssoW^CYv*9D>K5c!>SQSw6#^Ug(xcvNhK#> zWQIh+*(+>p@^1@D&R!*B6J14HrC+|)B8ml#PmGbjM6Bk$t~iu)*trlAcO`=+muZyJ z37|nP`NCHAm^34?5QUBZY|I53)zS$n(sm_fWWkH-^q0Q3*Vfb!a~vZpH4#FCQu2lO zoH1$iK0*`@Dd)KIAS5zL1x@bI{G(9`Xr$BoI4hTuPuZHzZgw$+f#-g*weC@Auac`Y zlAhDVAyRJ>ygvsuQw3jUf}u7LT z`zMp(UEM}jI%ou)Vqv2LOd8uvh_DLPoGLd2%?Y;^En zh^3#3tz&>ML>G|LZZm#q?_Cvj7St)>$1S9LKiTV6v65fP4uG!XmMK`@<`U>6E%a6V z_RQnsxvs)>wfjppbW{U~EFg`cLAsI{;=13MZ|9d6;qc*qjw?5kQS2U*6hR18_7p_?N!<9K zEqUOd%@YDa>I`^|%@UW3O%7uPA7H{7g4UY+0s-@aP%UiA{Rp&gR} zt-&9&EeEk9%e;<_JkIiV1fPEIe~@yu4kP!!yWM$GoYc`5>QrZ~Rnc(tI256Tmdz}r!hXaIqv7Cas$TZkSC5K2jJT{l%qp>hE(i4rceA(2kR zbEVUAx)7@DDHtgm;Al&+L!mTfREqeO($tu|wbaZmXO1fi2mfYP8}v?{StT1d1gTdv zg*FK-60cNbh2mqR1q3jjXU*W?mc)x-*0M^cNuofXPgg`GevtNpFojcKH^fr_Gvf71kNjJZ z@p3GkBl6-6Yt5b1m+_+6OrS?au$rf?sbV{ks)5VnNrahTUL~hIfXo%npaw5eTO*?D z7Y~*#ZD|I!#&!b0hS>T5*e|Q-paQ$(MQ_XQ!s%Hta;C!X#K4BR{%Sl@mNC_Y`o98Db-|AhLfEIuD%p_FIOL%w+w*diA^@bS|Ih$_!_08w>nENz&! z-t)?6MZYpyIAQ>!#T*VWTKY%;qt$}T>QqMdtCa2VnkchtRmo~C634`mpHD(Zy0`## zF+OheKPATtq^0OM0)Lm7t#hfbkWWU*X*=u)PAuD{uiY zB0G7cx=6{SW=QQirc%L$qIo`qm6*$4J-2%MRT%LPoiUz27^N_c2od4(o^Kcb4Tn;2@5 zZf!m(8@Zd2ih)P{E)#g3J+9XORj5(t2%|8U zue4Ce>RW{1UtcmxR+RvuH+t6uoV81j5fDSH*`H!)k3>dmQq-+y7$wDOO4a@Fy3t+* zcJZRV#em`io(1SU;+}x<32pOL81pI=`6KiK?UW_-7u$QV2iM3-Z=})P5Wrtjh>K*t zrFK?$60d?ArS-QH{2NsM6?&R8e`Q3fy#g}@4m9B!x+H8)2M+Ll~oa#l4%z3 zN)GV9QaoOw#k%T~R9`9~-%q1AEf>u$!O-8xCc>BH$bF&7x>HT(fhdvzOBtmwv>IkL zm82;N*BArXg)SB&%O^QJCUPWe&)PUVswMsTSx5v&q0 zlGugrWU<9c(L0jZ1yIZJ`5kHHOWqO!%APS3z^lMD0lkp!s{p)9l`J6K{3C366`sEe z$7M@()e&kuVD)$*d`!e}W?p%h$TkWDGme3%ru1y+Pft9Witm2sr5nyvnf52z>KVdd z{H{p{m=zu#fLS?r{tZ>Mz57bRjJ;AY`CK1%CbZZ7;{p zf&;-qi?5cZH$O0_#24f00o7vtKU9mY2ER`UPius8M3+Pde6VO@dlpG>LeHbhS@b$` z2;LVj|5w5qhVQES!^hV#lxt81i&GNACkyH|+BMv7_|02Q2Z~x>q$?+xlWpmOVa-Wm z!nM4v8?&HMHCSXx!X79;X;QQ)cu_pQHFhc*KW6%8i1?^v3d2v~`2`ORYyRj^qFPvN zYTJAS3zO(SnH1YfhW6B(e=%fQ4TWdyp^!XN{0R`xe9eWU^+r*%zo;vr!^^N5uE<~0 zH_KyaQJxdotE){xL-e#nHI@El3o$8#62$b3FTRl3(v}X5+PH`MyquR-Jz)Ku#H=;V znPAqC4m&Y-)w**A{{B!e3x59U>V%hs6UhBDFydb|7kVgM1w;uXyJar=4}JSi{*qN# zH8pg-yDH@m^$^-ui!W>N$%M^k#7M@eXW^pCpWSSZ(6*O-*QmkFZ_jQSSw@s;)eUM* zzN8@t$4T)o)3w_}=haADTae$+M88b}D+aP2HwTXj2rWFZ-P`(V4i z{bv5ajS$b>%``TGq4{Ntj^T+hx`U>$xgD}FdG3Q_Ny4-}bFQ=f;%~NvODa z*^A*eDzMEFt^P{BRd_agM*8t&`YRY|w0ZPMQa;%raTaOq#@bfsfv;NU=>FvA5G8$o zHX~H_n3(z61zv5isf+(()9jWRY)nd_R_%QDnCjMO^T){d<*MN}9(s4Ro~{zt*Ck|Y z?T^;~IWAl=yw!biK8K?Z_&!4@0N z$0wC>3He;${??l|?Tf<7~rDMD^j6HP{qoM=(3;xZ%0r?~c0 zz0zcly`EN~Ed?Pd5NETvRxvRJThP?2t0GKxsbS?%4v;T@sI#LbzTr1@p$BLRe@;L7 z(JN~SH`giqH{g4T4GFC-#Ac$yuXFctgyjk}rUuw@)Dq@4%` zFUT)-ekd1v;Ljk(2Ozzd1gTDL==_8nBsM&Jrim3=qtN*agUogsG-{`jc@NY2=_+a- zqb$x8v1!Uq>LI0)#;tLZZ{}@6_h-gDcG(Xkg0>Nu#+*CKKvKk;64QWMveiW*$(oReaHs9CzcxLR^4 zSPh($vhW4?Prc*aeB~JmST1f!g0lZn60Hegv(madFy|c!hk9K9vkw3;v8CWSP{hZ2cF{V6UV$rD}-qfV1 z^@r5(((+(iB)OJ{Ks_Ix#9(P9ZVt4^&!GkgN_!bpbuWd2^O@t7L0UN~@i7pV@8)So7!n#^OPrYK{s46@1bvAe<{<$)-Ch{KOW_#Dcy0kHqa${fLNA_ zM+$Fsss=0aeSZ&XhFIP$E9lrPmESwD8~l4>2E>kE(6RK)338brL)PSXupd`6rCQ$B zI{flW056p~%tlepmF#AdT<8@*Ve9yk+*&n{v> ziPif+`lgSWMxQW!lm~9i3hJ22=lyst!h|O4m=k7bTXiG_zO_tmK7Wr%`fi5Q%xG3f zuXr@1In^eeTwKRoutxBH1vk%qkh-JK*ZDYTa-xo_2D*&MFnlyes;tdCa?^?Aw#8u9 z^JM!PO>YU(_csZ~W*Lj&btTUN_rWPzPBV-ErS}Y^`G0zSLz05$Jy4n zyJzY-I(`L!mi60flvLTkgt?lEezm!{E`a4Msi-azWX+`1JsoFV%JG3sn8AUa^vHOJ zlvxnunIi<+yGo9+!=UNSg8gnYH7TaH+E>k$hy{-|8D+OY*W(2L&B^@J$U*JSFW(&V zZHGosXQNO45Yt|F9YsMR6I<`?99$P*OcB*4p<7l<%9w;_Y#}k{ zIp2CIYFyat*C6*}m+Y3~U4~0+DAn*-Cxau{;^BTNEm77>248?k(&yeRaDBVTK#Ws5CL8wXiNT!vOTf%EGIPm(#=m39OqIjJN#+i+T;0bvV!yZrrKb zKQCZBE%zwUn(bUI!GocxGBWfnnh4)#MFMVr+2-V_K$|+2gwv8;qStQZS{S& znTy`Tb{9g}owmJX0`PD9E`)SJUyNV4A`G8lcLPk-@P}T&6pC(#20wLI&M#z)6bCkL zq;@OaD@5#fGj$eD4%L^9be1eb%IMPLG8OhVs3&oLc7~zYqc&_vUzf{=cv?xQh5ugw zxj;t01TK}ES=H`qRDAvr>SjuCHSw4m6;(fmddwxbnz)%p#lm|~FCYo7CLVLMLfA`U z*dcH=aWl<|JBQ*-39cp{bEBfg-4Y8u1TK}ESveHatO)Otpdk=kDz~$0s;_Ag*(E{0 zAi$asoQ4HaQ?UC9d=jdBKh27K9)dmZz=xsAHV?ysm7z3v8L&q9)2smF|DU~Y{c+>Q z_WZ8Dq9I^`U4Y{7{kS&?;5czIk0eH%xr5y=hTN^#!nCD^T6QLj{o@yhk0Pt;)Wh9s zM}4Q+1`@GYEb{P2^6>CH{Hb<#tYM#pp68_$Jfz%sl7@X2dY+deuRhs+^amY`&P@6C zWbi!k(P%a~&r4r+NVV)F4Z9CL=h{@9nU1R-&_6y&!|p@Rxpoj|rk2W2HQ%v@eGc~X z%v4F`$?*9H9gogU{qt0}8(70W3qH?FWmBE(9`g@69-W(-<*CSd;?vP=c%GMLBw~hp zx`sUrJs(V+@hN>`%KD$X5AQdJ?d5|gfKKRtN-ewkpNa|gr2qM({~2m|tool%&a|JD zKYM%ZsgL%jI*Ok7WbZuna}Bhf6hP<594O*}`4Q-Vo=ns3#7Fy2aAA8^YM^$&Ui}p9 zx|b+VP&*f(XQc`{6a(NC?Yh_5{!{$-pOrr7koEB?+I26AeuB~PS?PqjQ>i{1c}BdFhOvP6zkYW^|9(ne54?{)tac{u4I+^HLo>nHln_RYxU7X3*o% z9zEG=dTPr$J>NUwnW>Ntxhg(M!|t_1eS(YOGgBi~=c-2P_59`Z%v4F$sSZ)6RwY$> zzPsf!Qzt#0y55NoQQT6bZI42wRGmy`@6_%`I7M!79)()z$y`00TCLQ~zj!(`&C-(@ zHl13vl&krN{WDW9HK)2DJ-K=*qilHUKdG3`N&{+&liGrYqP+nek4@3*Uu+uQA% z>rK+Rx^0w5NA*9MwBYNrl`pK8;*slrK9cms&0&+L-uw08?RNJc>_7J(H-}%Z_rLwe z?d4&Aef^&|``hhtyWjoC`pxbB`onSapa0`O|HtyH#qyi&?dAT%?zkk&mxukYTlk-# z_P8X=?EZ4OU_^21xilo+AQ7=(L~u{F6U0hRy&`hKh@sAMMZ%Q`l%YzKm2{Fa$%)sN zYHKyo3q}PIRCA*U$oh>76>>$n@rr~n$izYp^+p>40eFcDZ+S*7*YID6{wjzGxtdxh zGzp%90TCGJVf!Dw!9A$4HBc_>gTJmD)HGL;R`q8BLXvf^bc>7EmD-giArS z7lJ4TQO!-rc4Wncq)t1i$?@-3M!J2QKtxKQ>JEd&0%sTS_O(q!vzU3Z338J zYI-9%iJ?>ijbPn@@eTnz!hprW6+-AR=Qz(R73Z8v7Ul()6#mD>r9(i&2{%CBG`yM` zBLiWZ0p`?7PE;6P9>&fF3{s{Od{(edNzVmwu$D@bKESEql6Xs5o+m5rXqd{Ntf!#5 zumVvp70^Vag~Gt(aqtT!Rz@kT8j1)@h03KkERz}aL~cGO5Eb*tnD_ z%B>)IWhLPaSf;29K#7EL%xWSSqu#~o2UE&Xuazf4GAiTxNu;ELYeOV!_7y{|H(^an z$m?J;EXCAmXoPlQ_psqLBPD^@1*yA^3LCa3s+~%>;xI8lNhd>EFh{t^pN1JLHB5R~ zJ!Q<{VNc0rU}2KfI!9oo6`>x*s8+%d53i@*!lcu%$Z#sT$dJ=ci!}Sq;)_4s9PZE6 zo`C=Rb$fjTeg5B9>+73$>%IxmIRD22uh+^KmMQtf|9y^hKmIRTXWegEo}K-So#*Wd@ihf3+t;EQMqu{@3@?tXkvb*bkg}hU_>}I|83X) zPgo`N)BOJ&>0#!-Aj`jQcE=Bw|NMQKPIwDNmM^aNhwasR*&OB;jx0+=mSsMdEjVxI z=Tp~$i_Q!2+_m7AEdK$A(cSHKw@l}xh0tXA_v8BAG9OeH!ja|6&2D%5`}N0ls3Dy} z@Z|?tS+pk0A2+{U{O|qYpUcd=E{r3~XNT?o-S42aiP2kF3GeTAo7>yXW|`Tuvsxfd zH~)Xz9^b9D5JKkt&HAuD?hknZ7@z-KYtx(mhMA}N|2fjb%zxRLzCp>=v@?U8W?|cz zAgdW#6nv-BAg)<(*2#vW#T^!qNsfhgz8}>0MCa=%WWDHoK8Rcv!gQMof|7->-Tp#^ z=(AvuRA(Tg_JZjSZwSq6RHx7347o76i-i!6)O2VOBVnF`Arn39`Gu3+VFFVigm?4Z z@nn=Qd}lP{nQ-AdUkcV6z60KPfIF)tzm-m~{@-jr?t>4BA>R?B>%Y<5_T>NCsHgS+ zv!wg2|1UlqcH85J!{+}rJ3-0%DcJX;ncu|SqGZ2%x4jw2{b7IA&VByj_PBrFulm*I z?fXr*i>S)N?Zn~tb{cg8A{vh3%l$_@Fq|U6aTUrf>7cW2k`s?;`bMflK&CT_8b6bX_`=A;a{81W+ z?!$VJ&}V6=y3g7J)_b!Vw%&Wq;PswF%Jix_;*Dz$XOB*KP+h6bB{$57D%Bu#wmz&14%CCpIo1I3v%5D#EUA`IH zx^y$Fb!O~%{qC{%`Sv*09nM2o7p_OI{!$s4`fKemtDmIds#F!T;`e zw;+63lI7ob+vDcyB9ISCeZSdVZ4S4AJmpZXgaY36)&9fr9V{n*|9SQGPb&`RG3#Bu zz%0q~>;3+pm+M=oF5lpia;ZBjfk@!Y`~Up=(0``1>h%BlyY=>PyKL^NNx8nB*LfsQ zR`ih0IG5G92ft+-@L2YMWC_J=rux&-;#{`TV8?(HVRx_$CsaLD+_ z<~FP}FJ7$v_AJswDAr$lpsw|s&Gqdv-`SJq5+5OgBFn$Of*y+%XU?050Qj-uyw~HH z@47ucu6I}KL+SIYpH_lNsmC$!Vt+VZ{B8Z=YIC?+Uxz(Ci=w2Ia9FH?@Js5nwzdO= zUqF-Sc3CMSkhYe>o#CJ#191QmuYQTdu?9!PtK*ZkZo7MXyUfZbq^P6Z z84>pU2?Ka|_H!VNa8japcy>50lL`)LRC4A~07>rriSo<0&sQ(wRw9JeEp7nxq~7g! zVF}49NTgCs!l>ZSzsrDwum2pnEIr59g|D|^8(ubQR-{tBnumuV>U#vFc=j?)2`}&| z0spt&U2bldS;vfIoil_IoS%QUt8J5O=?AvWqC7`xow`|R<^sXR`4g*NAy`;8@f9NL z6p~8Q5SZd!c0-(`@@$#=L?wOr9 zY5cIh+Qx%;byu&Y4Le+m4(XJkxWnntoHDXTG$~5bGwD#XlE!kQ@o<(uZ>dy)`E+_>%3pF-a6Ut{Qvo8CAb$85O}rTUA#D~cb5Qw?k=e~^Vs1+vVrLk4i{1Bois}D^GJXs z!Oov574N{k@(8@q_k49!Z>g? zz&K2@jeq@m#f^4T0=XB5&HH_j8~?a@b6DU06E2e-1MQ{GX8CiL#*IveWX(Yu^NyJm zkdJ}_FOwK%#?dMnv5;ccf>1&4UKQt&FLyhr-ikH(&=QNLu#v?St%&W#leTcHd3&}9x=ZX8exx^ zUr9DK_}_jFqV1{e@TbG(eZ3EAi=8S*gt3hg5~(bc=Jgw!2d0__f?KV#V4h=#sgN&v zq-X=;+wWIgdNl!v(c_~+7esEPnvk|6V=Ph`Xw4%8MjLzXw1MsM+4ICS2;1ErpIu&V zZg2O8ZIr+TqcqZ(sRixO=9HkBk<~On7K1oFCXbK5e7zEU@|x-smxFX!r=09A2i0X= zV_!!)hIe5D{uf_9(huT~;Wkx;MG4%Zg-GNh(9jI|D=V zM?oC`#_UR(f0*_x&E!m3EaR&NAOSX}oMSAMjNo$*QyIIH}CZ=g|NoOU-+7 zt9^9_!wL~%%W(vAQHl996<^N+Y4Meqf3g&$yvGUjj zPRyUY%KF>$6*pe8iIwz6*!4bcufsj(pRcdCyZttZ+O!(nDwrR<;%oBfBwuB{>{JaBV}il7t@KT$L^$X;tm<;i9q9Wc`9 zn!RwM2&ZIEleo$6lBUgG{Pg3BJIkl=@$LF;xXSxs3#PvNBba?@9> zFqJ&_kj9q}iUf1c-K`ZJgvYP;i;OZlCL)ceqM37{zR6a`vgRfmJw(L(i5uLPKdrcP zQ=)sA@7L?yRUpFUyKSI{a@_S!5os!L%kfiG(UwiVm`LND>FyK3)k-j)dsS4+yNAgp ze|m%X9x7uKhsbDLn*559;%RIi@-=@HbVwEy!Ww)t%oY^MnHnkX)etgharS+#mE|on zW5?*|l8ZDhx@M(?mXCr4>9{1B;RDB|b`tkZ^{XAkQuL^WU?S(N*u zL)t9&lCw{}Mx1Z&^PQm!(|+jAF!ytKtB#rr1f78 z*#b7}e{?NPinnQm4#u2+r>pZHlYZ1#IyuiK#hv!7v@o{PqA-eET~2`A?#6t*Vm-lM z&xGaGiaT$oZ-Jh_+g`5U?mHJCh7TP9Ial3daJex{p2A7Xf%rT!V6&n&YGFp&tj`~a zfQdx=%H4?d<1095`>E@J@3z-(Hn74MPnb>%sZeuqV+ZWL!WPkZN+&H3>hp+z(Z!!L zUFxHs6@!SljgU;Z;H z+*DU2Ua+2GNrHkhcHAg9Mm!)n0s_*1TdNvM#e^yQLRZY+>23Ev!0!8e}ai%SP3cQ{)O#Vp+^10X7Zv zc~hlj@+u~-3Ul-hQit@INe5F|J8{RP%X*ink(a_Gq}{CnEW8Yx+63n*ndSA64{QtN z$~XoqA$0%~Y&O{Q?QhK}iy$r-o(VAw3#mi1+0Di@bxjQoRBS$!4;W}^LW4*P58mHl|dQ9UKLhO(_>du`3;A;L*Si$DFp6lL{ zw*m0#IcU(?DFA%4xm+JV0I{B3uivcSgZhv*tm9!UyM*9;K#Ek^c;-k$qy!(DNd$i0 zM93D;a==KXtb{SL5@|v$&Fp{eCkO2Bcu}4~?!;pLq^iU>Kd!hme(IbsZu>282V+c+ z86spaN-@0DOd80?LW8s>3TdaD?!`D-qzSAwlM=OHQF$v>u}fk7*%0%mZ%9F>l(VTD z(r-Q--mTwb9)w`UY)^}%h0eUduTv^5^d@{*J}!r#Vl(NX>pjvNlRYYhT?E@BdEYx{ zdv$pJYQ=?OcNQ-N`+RJp9CRd9K#?P+kyaozgcQzH$3?}M%e(>8E73nUhy1>N^<7lz zo`diBH^DL~jCTzKFPOwnQO*pVqH;tyQi8gWCCyUpz}JZtG-de+lLPqz=;`<}*G`}U#HMTznA&`IIWzt6SLT0USKpEa(g z)sIjr)R)cL8{deQ6z*aS)RHEq$af%EQhM5t)I8I*MhAVsqO(~TngEET4vRRC4!-6h znRm=oA~y6M($CPy|H~`z!f`*1{EiFbY~LD<8wU7?82FLoIFR#5fKi1%0;WA>FeiXpHeaNfYmL=5&xP4l1a7L{5qKcF36Dzj^2`3*lHT`ILMK zmc#YU{(5`cT^tp#PpGt2ayzb%BJ%E;qMRaB@ap`#+!8nXu3ysD5j#a~B_)pQtb~F= zpFd^lL#ERc;x^g9AxE^GNr+nB$h>l@@X_5Xr-qN7Hrx7o#l2zw3gU~+ikdStoN-qj9Z>PwbfANm!7#^8cX;mIQ!RTmVsu)h zsKlhiDZcx(XfcpBcnpuH2vl`RVP+fH#iX1P#eLdgJ8Q34F=4lqgENylV)IN=)Lf+U zqTJDXY+}UCbn-&t+n>SVmY&))U#+*hT(>(V*IS9Naou>n*U6iaO)#1b!ZY zGgU^ilmwB?DzK9o`X`kT5X-HV%?+qzS2YW+h@+lZ72KEDQoq#_eC!YYV_b9B&VvkU zwFBctPPK0*VjXDwIi0`37v;VbN!)NEdViJ+R{a)mO9=YLMo}@~9J(lJA}LrdbpN6< zXY+eNch&gmS1|?8cFbu|5%2y9%b0P^Pwu3mj>&r5Sn|ZhspGtUVmY{e9R3G7T3)sC zG+d=A+tlH}6$OXSQe{z z3IN7X7p>|zWm+wY7ZZgHP7HpQ{{}<8DUW>D7!h1r#6J#)Ll+~tz?`PcqOybf#?K-x zidyFR*MRc4)A*Z0mN)SWK_$>P!L?8G8+F)IOrbf`2sJ#wqWm(HtJYi(o|_pAiHD_P zvIdx9Ku6H}kW`?ygNrKIqeNt{jK#5knxQg00oO@OB z4MeO#y2&|bq6~=9)JG@G!cc8&1q-*Ryul+$VNm1JFkQygdg@qfTtjOGqgJ)Pg;KN< zpK0u4LU31xQgKad3NRfYN;y%M8pRBOkfXNu=a`qW`6UHd{2K5>4vU5N)L90bKHdNk zRAf`%nGUw$0F-e54YuJutqLTeOQ+UWmbyhi3GC#dS;kSJ85*vv5w&6;G6*iCNVdV{ zEGcWpeW?@3z6h=%Sotlir{N|%r7$`?a7!TAgsmNzp5C9=ma&3=*h67F9K>Rvd2>l}?v|gXlGaD&C^KCabWkIaBg2k*Yu4EJPdvU)TAk zVzAVLqhbloHkKNr6|oqtAat=P8x&W@(ZECTxV0Nig#=zThBFWt&0>s$<~J6-fTMh7 zh!Ip)^NFxOl>rWtEJMB!4RxB)oMU{`3U-Dhj_pJ-C|dqCKh9YlHVXR|#xuq6QVlmV zD_qpSk%;=l^4}u#t#h36&0STw#Xp_ zJs6Qt3GTG$KQfKM+#fAf`fplPIbIGIs+4|dViog%UJVM zFi*8=5s^dxt)x;5|1gK%`Z#<4vuo?1YLq3e_otMURyIF{3CA@Ycs3$h#;8`Lt<%Y5LaId(8Kxs-e z#}EUwQp5k46`uQ6o&X^$JODp5hBDFm2*g^Ff;z#=qSBlR6gDxraa>jB3eFhiGR{ZM zsL-lRR8h0`cj4pPO#7WV^cFjQC*J$tiP1jO)G+VI-}b=3-v$j1_z|@@Zc&c@)6yva z4=xmKhyRzH3;oFX|N9@TPSd}MbU*$-vfSMRK=)K2+FBt^+zd=Vq&!45MY@lB+99O? zu07Iu-qRx~&xbThsu~d8lInd#%cOeRRpY3t?P;ErXG1zD6;1ovDAnt@UP{$0OcAAO z+K{eFRivl2Ql6puE9Eh&$$ z7t=^eZ(@o`=~0(#xwaly(t{zSWRmq^l3yIqT}ti1MnAV@2p#!(4;twKii`9N1x0CU z7ZH{2G6swKF^Y|TfgT3xA~;HVC3;Y)OAO(qRSN=|Rvid(TF<-GZB^dGQ2lfWNG(eD z(bTE}hg7S97;ajP90FIXP!D1CQxs-(0TgSUj&uPRqYz_QOHWXkwVw6xlb+(>svq`n zl^za3D!+@MDZhyTDL?9xF4xwBPkJzfoJ_JlX!46VUUEAy6y&xHp(H==0VF*@v5}sk zuqaK%6)lTi{IJetRc1u}vR7(A+p;P*rj*$&HlUwb6&qL8tm_-p+N^6ERN$-&;VO^y zw+7TYt0LnXp7j6?DSOtHjp=>XEg#nLtXn^>4O*8RPz}WZ31RcWC&k}<0!BW$5BMZ#^X>*22{gv$^}Ls)h;psn_OrNTCFkz@XBSzA=cD1 z2(zZ1L8vt)aH=<-7=T=UJ`THv{zK4fsu_b{)6`*nHO(D|VN+}Xiuw5n9CMKoNLHP~ zVcGbO83tx4G6KzRp#k8PVq@^^lo|kNDK!q!sxQ)=Wm z=u)8p%*Cf8kc$Pzj4*RYaKI%mT*Hvd1x7&EE;3|=n+lCu>egijpqI-`SnlSU2I1G# zGYG(@#E?aA`osVT^Yd{SHuN6?VpGi+6q}}wnD*x8PFMivVgrE8&qpAci;RG>>Qv7{ zxbg^yS_{|Dam(R;iN3jTU1q?dxL2faU0fF#v@~wjgIOK7YQZd!TLpT?y{-EDmdW+w zK`Z4J(GiQ~RwV=0%dJ+T_R6i64q7$0O7$(A>&LjYb6o~PF(?OKww=sa_cQI_`XFV$B3K3iP_+$`FnR=MzdwO*MLb)+0E^>ng zfs+gLF_WI)a7hmbY~-5;M?Gv(**I6Kf6{^P>?XrmORe*b>amTwx2~@u*<9r&lMXR2MoLijW82q^x6<&jp@ zJL`yINJj;;id7FRa}XCqgXSG58IzT^RB2P`2@)YJW?vsZx?PO);RmilInI2)fy1aIp0@V1|XX+j}ZS{A6F{F$VQ*esyCQbA{@! zddXbTU-g=L)A}SR`4XimH=xaBuGm=~ZIE5VT7fSPQe zD{32{T(wkb4&y0<@c~42kW^S+nGXvhC1#H*CAZ2|_Sv2);i5sRZFdY}qbNtMWvI0% zScza@^rCton5Cc!>|(4Hr@<+#A_Dal;8<<(=&6d}*OX~c>UI+A4`)u8V(uxZk|OvB zp@v8>3ik$9kg3rCLsf_&=!257)KqaaNCuaf1p{Z2>7sz`z<~<5@F3Dr;X&h@;bY1z zD?h<>!inVi1MG7osKs-GcH0(K}R(t+1?0AmC-=?Qh{nE6j^_fXy)(-7&!F(0b$N;?Q$eC_@iAMJFPSsXZ9_<&Trc$bzCiwGG zJw~J6LmpAGC^SM4z-p6dh4oX|qM#hF=8@pRL`a2qlttY*A*oH^#sY0#x|2f{tR9T3 zh<;bgt~TvNmDI*U^@`85D7%|xREZd0S5XP<=e*>w%{GQw7@E-@TRGbFnc-6W1RGgD z)#Ecob}Yf9A%Wolj%C$R4lKB90g#AL6X2SRnYmpC@fEU&Xr`(&J!g_y zrRvju8gn=bVgS5%kIT9I*?r4A?p(b{2@ZoS5aTP`% zZm_XX(vO#bMP5u(#*@T7#I z#EaqCSa`6qjveB4RlbGqj8ROHMRIF(b_j@~W~uF(>M$H9$AB5QG@ zfFzB!q6G{Id{Ep}ISGeO@Q;9Ex$;GJ$G8>5V0>A-L7bWxA&5~}oKT*%?HUjyu--)1 zp@ciiJi6UIx+!WfR;@F7EV>?m__Y{&(dix&!G&RPeBd25!G&Q0QpY#NJ@zDl61w%S z1t$?AETO<*QU<>4B--EBd*Iini3y^qo-M92*q})#T;=za3Cc`s-*FLAk_z3Do&%02 zt20zt2sF=wqd!3{_yf7y?(*5wA_s)TT1%7w-;WJ$P2*HVaq59B<&|8s66Z2+Wx1i@ z4j3nCYMvtdz;IMCqliU42e-T;_T$*!!`raK5E$bJ*RymoCNcy-o~o!9TG(L?k5O97 z$LB>|MVo|qM$wE{koBA@*}}OF9|DKu-~^is)GHHHQtRr5g8|eDD#l)cBiq_T5K6rf zJdV04zjOLS$Nd~qp>N~k>(1#O^Y1Lu>0FK@sSvvO61&q9iwb{RFImZ{k4OWtjCSV% zwFgdxz%yJkfBy8qi#*4KrNS{$vGm{pJM&N)G&t^#i$d(KGcv5}59f#Zai>DX$`>1w zEH=1pZpgu$mnPkjuQ*qr#w6~>NS$jsov&;pmBASG@cf9*L9KfOpcvQs~sD?>-RR!Jjt zqEn`OL_j)=bmuTkiO#ia5cER72p5GH+|Q z&Ual2IK-1ehCvBdhCy(qsZmi4&xOES((tkrjKp7g;B>0+E}uU&#EU+9@0{)$@Z&7f z38Ic5sUht|q>u+rrwl~$`O|}WNoRyE{)ZI28~?*_#U8o;-$#<}=l?fb zgY`TBSC(R3N5KEh!h_s1K1g%$n;Rzt{H`}%2;uhYUJ+yq!xAFN_CDT=B-{G`TGJ z#`08Fkk}WJyM*P#T1YM`yXPf&z+l?#4=pQW{N<^wG&`S)*4)bnwB3A)C32T^0{;K@ z-Tt@L+r##1wcD?5H^(Dvq4gPHl>e7fTGr$Lt>#bs|K~_wTx~yIY_GmtUhd!D?01{p z@%80?cU*6Go5S*}#l=NaV10A*`ewb`TrV#!uh+M?UoLNNuD8ck$h&|=WOrO0HXk>K z+f8^gz1uImx_r02zDi|csr0`W;Y%4q>2uAqT+{Yy^JaZ`9Rt){?Dn^t>&@ly0%^|2 z&EdGcTsL)m)zsB=tNGaF_5ODA`pt(U2q=2rsvnkGI$cBxzXnUQ!}6;y{&cndSbe_p zGq>hy^X9|b*ZZ5z?vHNC&Gm=3+uiF(+x?+~idONf^>Mw5<-c5h13hS39dlQ)meuyS zdEa{d;^K?joAqwE^lG<$zkxPGvDSy%%^qp^n>`#ZXZJh4x4FvkkNVvWli~8X{kTEP zbVeeapfm1K8aA)oUhj`zE|2?Wp23&O&+a?z4;>Es-;g3zo8Q7Q{PmmP(NgWoZf;6zxjaTsc^>%mj;W$`N=i@=1lxBDQ&ua7j<~Y>wR73d0 zNP&xs+ndex_2s+GfxSjB#fy!NM&K6WA7e)!>f@YywR)Pwc5 zM_{m6i@JGstU}d8FMajJpThG2zzp?g;K5M#CxJLD@(%;zrs^jU|3M&bM&Q3Yi1&vf z9Ni1RQ0)_V9}K)0l`$ZmA>w}BULO&*qhHmhVlGwugt`YtU6;;(5bp5I<`4iP(|>)r zWXlc_n}){U7%N+!1JVKD{r+lmvAs)vd4$IaW#;o|uF&1SW|gkxLm0{ZG#t#?z`<0&ZKoPFZErQxgFoAqTp z3E>2M2q^F;DAc_<9=5x;_eVm$I(=MN5KmaBdULftZjRgcn^U0yZp$HkgL;C)18fzI2F>FFgMI?T1Aw7Yp?X|!Fi&uJh_xZ=&^<0F*e57F#L5trC?6LT z+!GWY0t(yXBg27z!ofrA4#!7#gy2t-#|M!-R+{vW3=mK9$A?%NqAkTEgTj;e@gbm4 ztZ5z@9G;|)55hbq1FA;`h9}wMgK&_QMcpF<#FPB7?#-{)`}OezcmR{iM+Sr^$>W2Z zD6&=U`J*9^W39-&{&3u{e%oFh-}Us$2PzxY2?smoem!Ff>(!6?e+TR>uC^byT^;yF zHSGO*yUVcbE2v||DxegV#^d_<;r8{LHDVvo%!ErYBFB{Fs~QfQ%l&S*xja^I55G5k zZL|oG@-p@c3b3!bZ@&I;bF~ILkl`vOhPa;*wYS1z@^O9rVe{p3S*Fp=hvVvY`+qlw zwo=(wQJwAL=6Zd(dAGm5+8p3DC>f<2e~L}-^rv)W^=5roRd08zk6lTMVvG7oLlJP{ z(0whu<$Zp=o|ykyes%G$i~7k|yeZfveh(W_r#sK&A)Ai(o81TK*>Wb98bgw3+q3-Y z3$U%YfTbrB3h1=?*YU6p<9oe7q7%99=sR$WusW^}Z#O+vz?yuU+f4y)Hq`l|qQ_T5 z)akByGrzd`*G;XH`Z+#%xxe{+&IeKS&0OQUi%(d~>Lu!nG;ICR!jXDUaXS=e@_rNS*-JI^yqxfToG zsnk);)Pl24Hu%b0aNfyHzV{YfbSmeZDxns$f^rt>)aHfcHwj@H->awXjTPW4UtI~npasNqUb`4DhnbjF0`z2 zLdewxul=z{Z|e(D=S64g3uWv0p)pqG!iqXG#N1pc-HL;$G$2EnRwlPpnT50M%+Ma^ zTA88QUbYKD#K(nKt;`T6bK%<`m$9U8Wkv)GQHN~|ArTis)_Ebe;zFxB(opbIve3Gf z7h}sTw5fArOq+$ab*22gzia_|7Sjv_>&!`s*=}Jxv(72Gk z$;z=X7mAhYV~op%;w7r44Q`nhw)!L$$}Md9o~M?%g)N^GmI}47zG@Mb3s?QtE-ZoE zW%rlhpk^U-{+T>hE-b5Ejt7t23$EIZ9g`2m1-GrJi!lP>laH3OODE+2-)*n1Hap}1 zFwXyTW=-G!3%5`F|K~_2;{O{biLf?F*zCgEDPcblu+eeYjySM70$2wDj2?%@9~kz) zEDP9c1T19|I9~zWga8Isz@{q}@IOuv4LiMsJyF6QA>dpgfo}nJUIM2hVX@P|I9Om^ zIPjkmSW^xRqF8A76ekMUU;`_qgjHR_HZFh<(7>Gt;N1+cm=ZW80eiYy7-E4LmB7?U zSTF_fA{O=y4ZEv=eI@X=fzdOtWC~!-1u!=TSn0481`dw{Coh5X6tL}E*r>ur;o%6P zVT+Z(lS^Pk6|C46&PHK((XjJdIA?L-XAH1?3U&_({Hca>h6J8eE}$t6r~}TyU^7v$ zW=q(8B&^cFMM~H@;gn?HG@}*@s2sWkHAv{Sam2wH$OErnpBD1}F6o5p|L(*4&0%}l z-vB1oe`{rT{kL3-r}h7Hq*KX%qTC1EuZGhhYyjzq6VEjeb2swX5X~=1gk%yBz$zVH zgLvoRz;DutB?lH;$RzU7`M?g52Y^lGK1o(obC@xWq{Qdb3eU$7m5vb*6*QbLm1>Ur zx*USz!56j*5F%)ij^)0b_~4i)lRU+;#0^`Oju+14LkYy)4rgQDh&W_E8S8ZL75VV) z8yS?$Cp@kSPi@j61VU#g!I5sbb77Mt2!fQxvzTtqD86;XXpR;Rq$WmZNe{%J4zdAo zB$^Kv&4D5wBwUIQ2)j!bb9k0yIw~LUU6#?fN zNUf42!>Q(2?|gG&^EOEcASz21pTbt2PQJW^2m!t#A3W>L`)Yf8bG`okcJ*<4yM1%r zxHRkfaKBF)U;m|LUH|WrS^n79|NC#O7u((ScDFfAH;2;w*8jdkbi{A^q6!I_qC_rA zz{eZ|a#13<#E>qFcHqqtk#hmj83ffFW?0w^LWNvWZoG<9j9AE_-e?mRAfm!s5crjA zkta7Xix^_iI#@`<(i>Wmg5ty=Ig3_oohIZ?$Tp5jZCqa5(q~f4!W_qN(F!NgM44EC z59}2e5|)yjVo=9ng_uHlbq<4S$qIaY>$py* zah6oW)(-oB^P*P(pRh~j`oTZ9g)Jnl!l8MFtYC-oMy9PL;X-Pvz(sppS_3MiAS(#3 zZjEYo-B35sQd1swxUd0dx^Zf?QF%8^AmR{w4Z{4yjWGFCQNe&A1D+;jD2P26_t{Xff}rb4r)kI`%p#|03`4I~+~@-Iq^42=VqnJ$ zIf8na1;FU!cbIoPGJ}LKnrQXOfB2$AcP}B|7dfhY4l^GC8K#h+DiEBLP;=VB!#f7z zbGgWvg^xq1d5AinsVI$zj6~>ZjA2D#$Bcc|;Hz6ES_O(iG^Mn6hej9&6yS+V3N(Us zC&a=7z9bA-99$uU&P+u@NE838#6??_z+xrZ*rJ3sOu-Hl+^tMZbZD`oHeir4o#0bq zXg0Am7bQey3Yxj7!%XJblW7H_UMdwCzEGus$>ZQx;Qi8PVAW8O3CyK9ER#BLPI35B z*y$XJdeKCXP9dcO(zuOGRvJA4t9v*Hga(y(vQ3oP*mmWp*T9iSrZcY7DFkbd${H%P z-k5lv2rcMfGc3i_X=p@bqXHZaHBtsHHg(rgVZ-(WJbS_whlv3PlniMBVa-JTG|X73 z6Av8MZNL}oDY;CEdX)lU0YwU_J1x@eHzyLoM89IA>Kpe? zHSD%oh6|AXSYd#9=S-;@7I2&wWk{~z!CM-@{r zO`HM6tWQ_ALa&?qSJ=af4(RfOtSq=r39iXDppbA*3q+d#zip52R$I8deSfn)tdIM{ z2^W9~`M=iP`(MedF;DaV@y`E8sI584v3zEI&a+^UKDScoDHyW(dZ`HS_E4&-=d|Rv z(#e*o5(f6u?tdZjS}*>q<&U2KdnD<8>wgp5^8YnELCLD(yLK~Ebk~wy$9(PNrs%IF zw;czzD&0hct;#mBVXM3t%(Z&)*!^%ja%|Ph97DD$T1S(uK2ie7Rv&6dn5`b9JCl-A zMV&2~)pbj&0rBFc6TPquN%uh=co%(?2BQ109whWx8mjKI_JH-?Y=*7(UNd;TCy_F} zs*ZT$+QZqSQy$dbYxU6fno!2E*NpZs_a3Lg?&eoR-hEUJJkrQG{3YCC8Tl%&8uVUn z8q+Gj9_ns(8s#dxJ-~JOW^C)y&9K&)vE%i-$KL1L<5+h%4`E%n9=-ZYWn}8Fwa2V} zl7_3wsz$2Htp=&xj1VXqc!*~!!MI#;4MJ;RF5m}WF@M3>e}sROG1laB(avAK0^8`T zi|1f=3E_~xTXCTU3o)>Fw;;b+lI7ob+vDcyB9ISCeZL8DYy)}9p;hvnq& zKd-+2X~ly+mUs06(+=~TZ*WPu)SVZ=UYvdZpMM|v&y-f3{^z*U`L3Fj>+5-)NAhGv z59y3^i5|?XxJ2jy`F5Nh-~88#OQv+7$Khswh!dzwkiYG3FP`n*ZX&GPCl3aPjDKuy z!%Fkw#p-X*B29#1{k5kR>o?rlljag1A%Y^yzrTVWixr2Ei6EMZ0GN33_qaGJ*TqCDZhTTcAw?bK&WNz*PZ+?%v!4TDgp(4*Lx|rV zRd7h7k~5D2NOI>-lwZDmzIqwA5+ST^aRXH#>_!ENREkL$75w>k8F29RpF@|W=lHtt z^)_t7%SO$LRH|3=@DN0OkAM``Zc)$3cz+4rZsok#vJer7k>^5;b4RwDU48kl`P)K*GPt zlg7)xuDEA*;-vAz`f3{w;?-TfmNx8gEjpxAhT;yVLvzZ=8quUENzbH1%}N@}jmjH^ z#tz^yUymQapMAIDOiV2luohhWWq-H=UmQt4!6^o=9I9m^pR+i#;}nQG;3R2;&+L57 zR4OT@$(?sAVX@Bp73-~&?au$7Z&rePF#&;B>)pkR!+LiK0O;6B`qo z6XS_(n-ffI+qNg1*b_}`+xEnGPTo`J;@p1qUG1u_-fQpGt9Gqgz5DlXo++LbbG9|E z1d~fvROBKYC0Pb4h8Rdo$e01j_2=a}%?&GVs)V1;D_z^L?aqe_!)!AKXLf1Amew18 z-K3X@O4zP5WWZl&zjo9}V+*2RN$7}l7EEwd5)`Rw47IVALibZGG>-OnAJ0gh4<=MQ zCO>^(y1o)<28Q;vj$7bIojnc${~KB)h^Fkb(nSJq9gjS5jNRIxx53M(%8RURkq_~R z_eTc8OOaF*7g^7T-%zP0W&qw-Lr6E#gYQ-F)^hB_fYiVx0WqWB4abUvD;o;Ha5C4_#@OrGW6tS5)Gbr|o@LBlU3Xo=O5%Z>Z&(Gt_4E;29l|s(E(&_GF zqgO7<`b(UT?j@$49#5;gq|l*f#VL)Ls+6mQNoz3Zg)p81KcV#Nm>E)3H?udE%q1JE zC{qg$a|6Nswn@O}GY(Sfmnr(5e>^$6cK5<#(h_7Qja|7>^9~l1OPSDPt3ooW z^O#c2>!@U|PnCrDaHRaruEN1Ak1D><^CYgcOq*t zSreMe_i@68q~Ld5KWjnh=h?ES5w3gcY`pRGQ(1FOJ&9-B=ep#!an`3#Gm^4;s%*c6 zohDxjGKP`!di2-hB0iF4lIY+vOH25=sd(yjkD+rZHL8)YBg^(LRLxeVMk^S<97JvU z)!@695k2!&@rG=gWb>aOjb;5FvvT^lh?x_RtfZy*B6S60EPK&3}xJ%+3hiQ{M?V}3Z_ zi>!T;9vO=@NQp#H1}F!4WR5WgSas7^%TcNtmlepF?Vkp#eF`7V ziKzW7H`?OHV2Ib>8GIkDuIUvtbl#`R){^lL(ZAk*X-l|RFyG2i zt|IU`MXrM(HP0dQSCM_Ytj5-yP(c1f%|7RTLk-v0l~nfJIYPr0r6ckyva(9(UP>5C zo)q9SDw*Z{xhsLLIGRW8oV-n?klp=!I)d$yru0?Q$y$Jl5pB2|yUR7B2lXTXR;`-!h#5J5Zu`|A78S5}LUO z+sK^~Z8o6!QwrR}qgm}%GO0=?%y6trYnz0-^Dnl9W?;!JF;6@ep8G$F0>xigH470= zOkQ%7L!p4E2%MjPV=+^G@x6`MeY?ZBe&P*I$Sd7^?|%Mvw#s%&L}#7 zOEQ~I_+}X_xZTq`A$w<`RzpN=kTeWVJ?DHm)s+6QcRZO;L(6|dIaGdJ6B5@dtR%az z47avxlcRm8Z%#FQFy&@2ooKe=g|V;7I~Nh^!@rbNG3Zz%|}| zaG_dnh3!(zsR#yxm$Ok@QV{3-~VCFZK)eFEt zGSv*OBz9l%e4L|XM3W?6jAGNeuNw0QMkAaNQQ)$IVw%jMr`Hk3Onfkw^+&pROTgqS zslHtDo;^WfFx&C=>Dl{82`Oiq;#8B2bGaZxC|pe}PAG8W%mw1&2lvrA_Bs5gH#K9O z#)cQaFXF%V%}1AyAKUF;#6A_k>8?iDve|x-lD_;G`{0{g#`hv&Em!xfvX&k+TgpiM~?py$;;VWcVzJ zV*PgFMQH`{$a?6d=4`xwXofgH1>jb)B+bHBk6Z?yG758qeH9)*P#J39QzS1=0!c4) zUN4H+y~U(BUqCcefIfM#h4djkx}mD^Js}(uKc#=OJrtSc4v*;%hr-2w6x=2EpwH>j zC~0X;M`D9a2O*Ov$jClgo2v@dtMiWqQ#VWK|VV%h?cTk#C;C}dAas1WMNQ-;tub=dn|5~`T?g}gAmTW= zzyj-8)vJvs4bYmd{rcpuMSZ0Smu^|p^u?Tn$Rw-m1YAqVRf13_DItU%0cO#byhbG| z8h`M#BRLAwEFOCwlOZ2Hx@f1#JWSa9Rn+&gNj3EdgxwYKT0d~wg4xPOkkA`(OYsbB zsF4p$3q^G>Lw~C(KA8|P%|R_?$wcbZS9GOTt(4Y(+C{v~bC(pTj;$^rpK_C;TrXX- zp^DX;w3JKEiCG%#EYqdpcw-7NlBSC*H)Q^sc_2BB9VJQq!yI&(*ma%T)XgX0-@GT* zz#p9+D2(TVO^l(zf9*-O&?YIew=4Wlc94RE8u00h0GNFUNjKiq%b zZ5}=18oZy&E&GLhEDP7`R(pGnbTpN(5w z#yU#KA$YLC*vMe29`K_v(g|Kl+2oz-6bB0#&8{*cDS%0+giGc~!5X?IPVigMqZ4|g zo?2QRfDW-eciZy6`;CM;w>ZWQBSXH6BfU$z5dHO{?5p@g3a)NJV0kc;UJ^1O5lBWN znr@qUpKzs(!7==tR}%Te>&o{R2Nrul5Kun&lizIOdRxFPJ=Cct%}2HeYJB(A!oR!w zk~t1xg+^_SzMXhk3f=2T9;k>;@9F-j?+{7oyUb#J9+6wrl7unht|8W6FrCl-Kt6v8 z@0@M7lR&XHMJd7LXlB5x{(N$ksyZO0lx^?Zy|>kPdU<#FnQ+PB$7i<%&W2gV3wUKE$i=p2 z!RJMOdpW)1B&TGbRkyUhdAoP`I^aGXWjiCvknF8PkS?a0j8Q{SIJ_Dr_SPz|u+TE_ z^>fwj(o<;521#3BuZ$vnB+J4@ z94?I=!KgJ>XF+QY0+nhIuH@`0w{75s9$`Ujwe7aYqA<;CfwzG2wN}gq6pqiFsqLL; z7sDt@2Ojip;c@6{Kg2dFC|}d!3Opwi{S=NwEJoZ8e_&?Qi(o;g&HKOVj37(?BaVuQ zF9yPVqV0&rLj^cuCg{NEwYRt6Y#`h~q$i95f+S|D)A1sxolCJlbJ~dt8nCQvtm-F9 zE_jlWzgA+Pim~!T=*K^-j734=SK}$hO(p;6I$(kTJJp8UR74_Y>OvI67x_`G7@!GI{S0EqHehw|b zILY2wD}RaQyPzwZ$*~S0ATa{^;g6}?*1}FtB83eOsIX~6%Ci+vSouoRE3fxUVqX9O zoP39Bi_|QYmQ=S^Mz%ca2I6PnFvByVIpmRe@ZNYO#tDbn^LLNNXj-7RpO8|Q+=0|S2GirAxkM0v7c3VhBjBO%5>E7 zVP*gy$t}T8JqeO?yKj`wY@B!9#e*&%3Y1e1(UlCftYUJ36Gi<_sTRb~#OF1}DyQEp z4vSJg&1`K^(NaW2W9NI(yBug>nuKxf9`T$6MKxh_!&?L$nRi36y*Iq_A&8KTFnj_S(PerUEEY3RP`2V#T5I}JJy8iFCB@48GnY#g+Ia* zg{?wUTcC?Iz6t{K$ny_6s?yX+CF3Ahs06frFKIX+@#dNMo{Un+!|k+Zz{-tqVJYh+ z;|P!XnA$dwFJu=urg|7#b-FHCy!j%cp=jvAXX8GtF6M0J*_NhPe^jm(0~|qWy9DP= zfxn}qQFNQK&NT|@Q}lKUXFQ9X?~eCV!3;&wwHr zJ)Xd31hFGpG^8=PA?H!)P=N}GSNnNCr0D`|xU_fFK)K(_vS<`)ctG;@5J%Enc7}s7v(xtYVp4uv!QZ_D_5gz7qQI;*Nr}?VnEoQa7?Wbdg7Cl&xUZJYktAwo z7~-DRi$MWp>aamMOZUv~x~WgBYQn@2D$#Sk9L-gIwFCco9A4tZOruJ7kKkj)!()-OFi zo8g^h4tG351=kVd6)eI46F(t{5j``gB=vG9Ev_EZut4po7?GwgcEj_F-4OU;~%>yRi@aVmGS3*bS{Ob|c94i`_8#kKJhaVmGY5*p2!xb|c`6-4OY| z?1sWlj`2+zfMVwohakY5jtgc>u@d|yO=W-QmklA5qxv$LXEpnCh8igWm?Iq<%9f2< zSf8#sC(KsW>_A2hfe^}8)ee}aTqOEUni$27y*nDLD zPFaRQ!OFPV&xS{ok0j12L?UXWZv>{_@E7m13Ip;cnre79-3A}Zu*wwe=P9q^soF~d z(A<*`rRz&{0&-ky2=77#GW<4$fET4h%?ix*_7v{GB#@0_VgsGXX%ump_26r1X1o{r6IHd}}d?9aOoQ5f<^c)*In^5u)~rjzXcwH3Uepcf?R)BX63+WgNA&HTk4$>>ENN%uuNKB`@8 za7j=IA4;{1#Ns8-fH*+e0Vyhaqd;`$0K#{ogoa0NZUCI{rK}%_nytn)m3o%%w!_~! z>a?VC?o;e-SA6x$mf-}tiDf+KrN#tf);UFtU%#?IXS~wA;1^s2EZhki0&_H7GX+{F zQjII@r}cTJO?o}rXAD0?l)sTz3HHMjUlHCMu)oG+5ptYp5PG)23s!d6tdvQ8P0c$< z0xo)06aLVy!D>DUctk&`IkN+!YNwFtnkvhv#Y3?7(|-*C=JonC9?_ESTXSHWTLyqy zGfT&yQqg5kM0^1SWEy7Ouj{1|fhMhnu3&zQC?;K%Vy2*qbW&?Vi-8g3z=cm_*NvQE zOn&*8CyOq;s_7LY>YHu2i8riQ_*!kiuK7>1yKL*X5WH-Ug!Nc#MM^q0xcKa60IhXM z@}yEFm3eLB>#}o5zaDbJ027@xD1%y85vIf}f)tF7 za=GOhTsiA(t!x^d*>uOS+KjU?i}|C`>F`^7c#r zcK$6IoFU4)x&Y${`eaC!wS>EXn(!oQ4Te^eNj+=8bIiyG3`k6gou+v-5F{PBS}Z&I z%O%yfrChenD#2!*ICzD23}}mQ!-Sv#z{BgVAc3=>tAOalvmLT_La2@q@rCBm&=v5m z0z7k?6Qp$H4!#a#VwPq6M5ro(6B4T+$B6mU9f)ma!_lXVZ36dNP{PQmPK2D)Pi4{H zmuc2PYsBwV0KqH};il#}^K9{);W9p^#G2~_X#WIzBZ9ZN-6+`P`ktPz=P8q0!Hq@k z{NN7bM{9?i@>6#Wwh6^-diPI8kd}kz%+3*k{uJ>=!cKcJ4;teUyL3lqbhK2;)m1E{ zjEBbz5Lu>$TTI2AL-{l1uw&txKvKVlJau{7skDP?Cck%#qKf zjL@(svAavPc`-%m4Z5;2#ik7Yvr8?0w&E#4Duodt77eO?jZ(+>1FMCWcBI;i(vuZk zWtZ3-$gL=CgW1*~lAV5apbT9NyH8)Vz1Uc-zz?KqZcy&t`^GeORfew)7+zLagLva* z;k9duEyM}4_&uT#$K`9$h9%tpeM#w|E&esR-4#RYc)csZ7%a{R8eYTW$^mI1vn=b+ zXvvk(M$;dhm&Z;BAFES543<0Sowbmk7j`s#p7ELjvE>FK1JGkE!Q`d=;y_nBme#Vo zu3;eVgq66S7&toTOqD>CE0ZlMQ<4rUKYlzA#Mq`UOimg@8To@X1kalGy0UTgxVc>D zd6KQI8Vi~PIy--7I=AEF%yvmvP$IvY-C$C$!lE?`U*{{V(qoIyd*^nMi7c~&z*v`8JG zhDdNxOMFK~m%)%M{AwCi9xk_-GCC7~hpPhZrudf?xHtqU7Pq?MpF+%ciUO@#pTtcz2WU1t z!zBl_a_aAJCRjK$DVFw~Fi`L{+_UbqyK(IC4j%CiQ3NsS=P@MFwXkG z#&|@eo>B@<#u)(%w+hbjf-(gm4&Ao>G9hqsv`!f*fh_!ykF5_R&kxonK~`0k(bY&? zR&(&0FM{Sl?DK0LdLbz#Vyz{|0sEt8Na{EcllMC(O3H|zB3CV)K99fuf|EBaVNmrw znFj05^I!vWyp80%sFrI6+b49j9jw|Qhh?&|%Kr+n5~)wnngFN!nd%}wcpjf_xGq?s zhM)T%7;|GHk-!tm>CZxOerOGtrYzmvdI2?Osg3i19Q)X2g5|O;PPGH!edN46{YMZF zV}mIR9UZ$yVD3Rt4VpWDTOQFuR2-q=iyg+Ub3SLBg~QcO)}Nm{TsubI`J>(V()Xq5 zLzsI8uYL~}{n=iU({MSXm=j?v-t-c=3{)k*L0X>37Z$t$OtTqRI?1as3VQZU38cUv z$93)(p>>|eAf3Msycqj!R4O;z4TVugBRgahy09LX%6J?wOkr2BMRu~O9A_yWGZ4y{ zvwjy85H4~9ST5Yp0fxMD*10Z-ppi_N)^XypXai6P7?8TKOh$Meu&W0l{@8riFa1YR z+)@+)1goJ}90r*v`pdmYj~o;R%T&x zHMp;Qkf;pA5qXo-ZDDVe!L_3Q=(eFT7%LKUyXegT5!lax~u^n@wECNGj|k?SRamqEul#Gkjzi%E}h zq(jBhP9a!=dV>w~(l0GyH&BW5pLAOU;=^3}Efd%SmvCm*+9j|30;7qdFaR)W4g8cQ z>9e9)q#(LgbtA1B_C|te5sjyKVtwt`y}l*{%56!4v2swVFty@(0-9Vh5=8AwE!A2^zwvLnA$y8Jog;cb8ioh&J&(l| zYy0TXgj)SmA0pk@9sdLvZ4v6R%eiMJ{mWPP(SGvi?nR&6{o^n-fBNt==4EX=LXje! zrs+o4?Xt7q>2#{l&z_}^MFrHzgQ%Cc)`#}7vD@b3G$4L4FlxARw;PMmP{+-jpGL!> zJt(x$cC5W6`2khU=B=Zhx3Yx(rgBr?Adrep`51b@Z2VG>lN3F6|8{GzSMi8t1m~QFBj9t$3Nb(KOCC)dwMpmOj`ok7I^iA5Jl;Yz)4v3*G%Mv9Q$L>oK6Jq}zO`Oy zwrd9VTQ#V4rU6;eo1Z^iyW0f}$R=Ofc!^rm&&%TKvO2OHI7vIZwzeG%dS9HNz$LGB z9|lz9%A=)smyN7{X!(6H4_}4z)zcn+M@xG4TRxs|-u_LiAG1vD?VmS4I)4&dcM7{H z&(ixf3)N~iyr+*l7QiZ0n_kf`HLj6xU+dNx>iIbTY}!IjH@IB7=AN;jU3zuuT%|zv zZwencyzm=&;?m^(1on0sv+I$xl5$&RPDzJgx(y4;4jiSfpMTtrA3ASOe0x@TS@kXd zC>$^b5vr8fd{wlD_4?iO>s0BlPF_x)eO0nMKb-$iom|*rAgyKb+a#^3xpJeYVf!7|vBKyYMK|#7<5;!j5xtDtl- zNzMR&3U0bsLWDpbw3pjfNiS2dg>%^N7xmo|o|8uB#~RdI#KVvnDFMSk%cz~fQv3dg@j z^HBe~`;LN!|3#7K!t^TtcmeTziuB4q{-Raum%pLMcA%pHa_98Yx(?GDO9km`Rx$0H zCc$`(kN3MvGVu!OOic)t9YFYBW0z|f!0@VN-A9}6%;9tD&9PV;dX?1YBq12#$EM)j z`1}BUurJw&65CD08|GKP98-C}X)dJ~7E+;$^w|N`c27M}ChleWSncJxT~oM1%fy%H zwz@%|@r^>XgHL0TN3Eqld31J-7LSyNk|Rs0HxQ{kpTh&no7)Zbn^dW_Z(Ap8uWwtP zZ+3gWUh+}&X7=_x@|&VZcT)W2Y1?$Gl9M^9fP3Ytw8_(>Pg_q=H}*GH!H3wOw-kSd z=m~-C$y$N7giXZm;1;-Ob_R$0t?GcQ%W=~C?z<&I_u8B{XWxq}H~YL}mZNQw4G$In zK_zAn)M+7MPvtiOd%fjnJl&(Lit4k5hx{$_d##W1SDJr4oy+Xh8=!Uu`L}etd#7~u z#87+iZ!@4!>DhHqCK|z$|7XOl?}uTQ0kiu!_Bsdj>7aG6!&?%CLJse|36abt2XoCj z3?Q?ksRf_ZcaMtK)+d6G`+`ebM?5mg=iTqe8Un4RUdc2(zai zwj3Kuuj6B=C8vGKCu+GGyd+{awWw5ytj9d*D2{Uj5(_%nPTX9hpt&h3@nq#m;2Q6* zkoDvrd88brXfPc zx@-#oCY`3VIqb+may`z85@|HcG1e%6XfBoYNlMcb3o7xs5#FTd1{KOzwAfrDMu8vEUrg9i}o=+r1-*ULn zdQg^-Xx74If;7IEy*nHJcMIC|ZXRoOfNEI$i7cU3L1JKX{iiBWpjkKOzIle5 zNF*Pq(5@iS4TxQVVCnF{223;_S~z{U!F(h!RS0l^B!pko1aC+`1|t<@^;BQ1Y6K*d z1Pzu6H#|9YuykaQ(y{auHdHZE5CIHKvKV7gpCjB&E!KNfq`g7`O~086+$0?oH-?z? zK7vAs6j_obnwN^x8v;?PGZtM58H65Va7KZE>;rA6J$!g$QrPQ$ zG9l~)OV$V(d5%51}#q6mBRrano$oI!kwP)mh2;ZljbCZs=LBCPl1os)cjDe z%B4J9b>LI_gr<<$hM{l`2xMRk_9&1&uE{s!^rn@OLul1xq(GXBJ^?UedKm_@Q0~zf zyM(0QQyS8_VK?-#U>eHFhAvwrZzlOr>w~x(&;f8k8rEJN)6Zm+rr3E!N%RC7U9v2{ zYmzZ`Ho4l_tjSO`EQgC^eXMi4%8_A)&XD47eC3&{DWNKw76r*L zMN?9)0+FH(z)n3=ogv#LbEOar$nmqdt-BSoW0=&j#lUJ9IeidnyT*J*^8iqLQGB;< zxlcA9&-J5uq`19xq|4mdWu>brBs;%9!jTGWLFxeJZqS?Y8*1(c*Q%?5fOi{2hUZ`V z^7hZ$`0*OX`#t`l&&%cMeE*LpVH3(H#qdi8*ER?4MbDSlbos=#&;8f<{SDLJ&*^mV zI4w|=A>SwNvOf_nYt;j2q@zo>2E4h~|8G>y1~w)^0Q#+PA)prYbra8%mDc(c`m^T# z;khB&3)k)Tm(f$bSQ|8S6))MMpF4mO69IybeKynsIp_Gcor4&s{1;x2^=|*)6N^XI z?1I3aO*_PPqL+o0pp2bAMjl8<+DDw19#4lO-t)&JK>rz5zYI&K!`7e6Ar)JnhNH}R zpa-!f{{#Qn_qyb}-p@3_2_fCZ7{+=j{!a;XjeS?Bk&N}1w_DmqKf|*Z`XcVf!~HK?TF>vS-FJptM@@f{<~#JQU8*ei!fjuA|2cyOx-1`oBC;f z6?^71!__5qv{ius%QW!Q>?*d(Ng10tNZC^M%LWVEJ-}~v<*S>8mieWW_2$3-*~>E}6xZ}9 z&;7l|f6Ns0-(vB+!A*l)oIYI9pFqR^1sax&!s4yBk_QW)@?Z3!D)3|)ijqCeCM1sei>JN$2_e; z&B+pNK?AhzQwQrh%Y8r8PhI5K_1Td=A$CkS&-~Uj+EjORZP(PZ$sF}?sc-$swMD%R zq1e;d_Z<=kPkB#x=<2*f0LlsbA+JC(QMX->X#Y&r%W^lg5zMWyqoVrGqSd>WWhx+G zy1u`pr%WA`MHFd!VD6v8zk8)t`P`It!~VQ&F?2aO9W-GXlKc`wy;pb1@DYiblcaO8 zXmHVzVpfu32B4#pX_yH%nM*X8iyTbRsum?*l_pPJ3jaLpuiX2LOnDsVLxO7!1t8fAK;}#I zH3ADDnU{hrfEdfQ<{LoOivl60B-kX*UCNB!UxHFG>{5XEJpM%OQ`-0_(JaPY%8lNG z(uw^d_T+8uDkNfOaNx&W!&Zc18rm(?iU%w-@p;UNnhZKD#-1qK z$SZ&^lLerKEH&y@!&1a@D@pS(?<7R?M_g)zkMb2G+)Y;Fkmgt`UV`Rc8X}|W&BegC zO1a}6EXO&A2=*4F-lMl|nH{+UB7e_En;QUSdS<`-ql5gPoVg4}&K**0pQPKjNkz21@)q zbJsNHRyo_(k>O!4!Wg7 z(}@Sa`l*Bs|NAIGiaGh2Ilkl6W2iw$F#hy&r?AjRm<|&1r63JNl*Y~)a^31Fw*MqW z;wf>>@{?!~M0#hE={Qar3X)qK0Pz;1gW{h-x`UvXoRR~Ufy(bI2w+tAYA42QkhtI_ z8UyTM%n&EMax3*2fr#L0Ghb7{h4`C;84pX+h9#nciVv9%G3Qkzkms>u5}-^%P3y!K zFvlL}rY zcwuof2bTHmh{Ez@PbBM=_Ui(6Na9E9=^QX&zGu7}oDPe1FT*I74lA)|92=Al`&Umo z9viDmL<9rX7OP84CIwZWLP!4)bZfvF* zf*p5{YzMs>y3YP96!|UZqkSOb_xyR1Po+h|OAz)gC;}w@Mk>_l#eBb;0&qhI^9BP7 zYl~|_)N2eX&FwsZoxmK>*`5@E;Yhs75Oi?#u_adKR}`K@@}%Z*bGoIH^+&-81I5!3 z>gOhu=4PNFt*{TQLBY7lP!^HLRS1T>Ga2yP{DfUT0lZHO%bLm(0av1V&V39Ol&m$O zk11T9yx_KmsDwQgeXJBc++3dK;N?AG44z)#uG_TyK8fH2h+Z&ty9~$~*3)VXKHcCn zc|XW}W<`xE1usRO#~>AbzO8(

P~ipv7GW628ng}PbIb}BUhnMfgsbLSZGd|o*5h&`>XthEW&R2(L!DB^{qbe z21;bF9lG45>5fuPt`Lt>v@ko|$?h)4mO&jQ2mD>~gbd~_G}zg~C}wsD}9 z5Wc^@1Gn?zJ9OUZ!BuF#dIgqML(Xpf_-46{C?Fi;d6~jC?8@r4x?;0wTNh@!N`yAA zyAXIb1Zvke+s-`di-A2}R3sB9IST9XH3pInU5Y)wq)WO?rj6FEgZQM@*bN*8UAADc z8|7Vm9o@|B+$r4&{XVoXn5n#<&^;~qB})%8%%6PV!NV?ak^sM#Ea072Ghk>KVrDU? zjB^!^kL$9YzG1A(WUL!a20n6XAogA9WRHzJzS60uHVg;W&o*7_5*flSV4Za8$pRZ0 ziNZw|W2Cpp4kQh2!!NSv@_Vj58yUOP{Tmt2km1%`{k80{_%S0w=gCf<{<+3^e~PZG zws+*rp&eDHhQxSHcy=ky`s~g~bzEtyIo(sy%; z>qBNHCT8b(J9l{z8+RP}ao}atSf@B$I3cn%WNM%~_@t;UXfFN>g}_g@k&ZcTnmlRA z>$M?#S3lHa4)vwaF~n7fj3mZKW(rhOQVZ#h z2mF83?+Ci?rxfQ-t^0t>Wq<0QkhdSi*dpEdh3u_ju?H3cEN1c3z9A!nIOop?AZZjs$h4M*h$pB(rz z=1F>2Bj3!prXpXQ_Ab`+SFwcvMZ@gtO=rBmXj7xz*Wi4AE8`Aq(Sgz5X6p|@7>|GE zI{IV?`^bUcW3ebl=7TaITvXLZ)I7Sud3AM{tTuj$`<1bND9{W4=!0xK!ylZ;zh-uGNEF+_|f}>n7sah}YOfvB5rsNx7#hZXE zMwJX434;%SyJFAkbn?^VvmBSkL@65@5-Pn+7glX6GTe!tr3|~*7Vm9Q$lA|*x^`ud zQeQE8ZUmvuvV1TadX~;X88B(a6wk7JN{SOE&zu>d<>P-lT!$Mn>5;saDJwlF$JDKp z>_Bf?-QKHO zU*F0HLx}mdY=mw|FwNQ4Z7a~UGwr=W4x79o0X4awu$8`c>~6-pFR!v_ z&f+nj9j>bjyW-?PY?T*D`Sgg-SDXbp_*73;%NQQ|+B?O-qem?2H!@{^zT#U2|APlB z{j27=#w>cpc7z{KAWv%-o94cPuli{2wlsT^qMZ$QVNmS4Wy&Hw3{4$R?(h#lby(!; z`J`gA-lvXU&8~1&3@qTCvmWUCu3sg#v%Bc9@x-z@_^@a#7ZLErF5&Hmt}}}#?&8Ot znOgoW9IN*CuoNNYj&p~3v3b2dhN04tp9ZPco}=VcjOKIzIfSN=a^@M$Ff}XlbnQJb zRqzkM)aE@f<-`)k>&zl#GAB@wOZMvdrJi|~CL{xOq+#7gsOf`O@TrnIR`p+KCjLPT1$T8;&vvdHlwyV7x`&8z_`sxL^J_mB$Ws~Dj7nWM8 z5S^z|C3NCB$G1$YN0pBl)x|WhuaAd3#*R(D0l8UcD=fx&s!I;gY|8B@eBGKU9Jydo zITK=yRha>SJ({f%9(P{Bp?Kd^Ly?+P!=bfCP*!Zf<(wAgD<$;3!rJU%$?E5%)N^%W za3EwF!#S~W9@CR))uXR$OSiD*pIG|C9LjCc{Yg@vNJOgvuQ5!e z0EI_S;>H0>mM6Y3ElL%P)^NHg)QF;-y!dNk@x#wI>&;mX=0{lKId@<26;I-_Jpv%M1|0an7h&;BXC9 zEjyd(_~9k-Uhtb}unYxSq88LyCm6@H9$#xRy=aGR6ep;0@N3BEh$xpF-MPD@R}nUs z-A8i`CEpI5D+06g?EIGIbc^XW*P3(eu4UBO-ovdxk(yysNK7sSZ|< ze!qS^qOOJonxGs?V16N}XGxT)?D?s$r{Q~rIIhp|e21I!z|6z4x9hy9R8KAR7Lr2` zJvn!ZY;o7wXId@@Ka8+Bn9;sb#U?S-?9gHiHsnFI5&s(Z2RJqH8=N|XUuJ&!MoTN} z@TTE{wU#Y8=!UT-13w!_FhX(#@l!B>4~|+UbI`NN#EzaXpLTwN=bx74cv{CK`>o;S z%5!8Pym@V3Uudu_M%FtdCyA6y((sp@nv===J)sr@B)Lpr92a#iq-aC7vu)+!iiK-J zflmpw3LCj(BG9#U6gqps(Urvt_*QjsbGP<*X+z-v+EHgGJNqQw>YcFyg)AZ;Dq_;R z(7-LI#XUOpZl;s&``jw^$a>y=1HX1zfg* zCC4@jX3q*opPa-JhvLtVQ5Mj^p|pcwSJf#5mdlA{_l~^>9Lw)}DZca#1T&D5)2i?X}31i{aTvCb&gDL9+OosQAR7m387c-DE@p zgNS@$<#ob7u5tT(LLd2w9dBG!RsMMQE4r50Z z(GJRJp;*R@rRn14Uy!xsNAJU~`L`8!^w`_EH@L~b^(EJp;x}8Qpq5bp!$t~*GHBRa zIJS6k;n%r#GVoq?ON{i!2kQ2X^KWb@a7 z2u&AKe*7C&d+}w!rQ9}`ul9D;{f^@xM7n9``44hB6Q2U_)}LD+#ufWELO)6F0x?T% z`&2|jGe=faHVSS>z7FG7H|he_B1Ad0)a9|IiTyU4Ag)%Fk?B8jF%cyvLA}60%^1}7 z`5iZFO(ZJNWZGoERqToytR?C2^3-*Y-rzgOGU4v&n<)j{@mStIkf8n^0Ok2&OcM9`YItGVQ=jb z{8q0}S){Z4*Rr$#qzM)t_k!iSs4^cZbR-Q5ZiedZ#I;7}h`@buZF`yH9kc38Hlx%L z0wiuwV5NnS1WDdD%iVa~oooy_f9HI=R%#f)H^#qLt~-S3oITB=G;4V6&9PsRwdf#4 z5Lph2eb0|m7Jate8Y19k0n_Fio6S3jO zu@_S>Y@`A$ctJ5*dhNsEeIVtf0oxVHW)Cne?s2~Wxq^)u43s-bayR75ErLG?EntWQU_f}gy?ZyF(e+rU0siZNbr`*`nh(oST%G^)KP zwAA5C66xM!U8<)%f+f0CTO!v2_do(7VD$DT`Ww*|Bs);f2{7cwmnsFuL|X4q_BS4# z3a3t!KGa1mCJ$<&-V;fh(Z=*N40(&$$|Ck6U@5LvpP{smm}jxv7yx2E#8j=dFVC6Pj>xvO{mK6%@6WXoQ(2MlCaz@7v3 z)Ks-V%-)b5#$g}(!;}`6K=^wl+i8@ba{~m0T3QivcDwfz&(jvSHcq*;U5eIwOd;DE z`EuI1+#&-hZLqhQ9Jyh$?PHR{T?qGDxL-i`*7V<@3jGI(#ilHyO`5l_weVC^QW~lx1K_7X<~MzTon43TU}f+4k+^ITdCYqO4Gj~!GmKHOHM(k<3mx~3NI z3hHwsBJ8GEf!2-yPg z1HUPG5P1V+VzK@{Im=S~wJlTx!&hzh=_(T)as{y`1(?K9gGu;SaeJW{S-x&23lVwJ z^Bhb0(F&bN<4~EzmBPb3ey&8UF12O;M21c^-Cx-Z2(4qk+ilU+*F(I-rad99{wtZQ zo_3a0w{MTxZ^)$|?{@pX(HL@<-l&+#bh)#6l@x3Vz9OwXHwE0&=(-j-o(y`d5dIDn zG3fCqpdy?UvF#uJh|u`uzeU9h*ls@WzqvF)*5Bv~k4xq~%%4l?)XNHnlf(*%u6|;@ zE)(lEf;ToPq@3Sfc<$en_oF?0YpQ$6R-~EcMK%u7xqeNUx#xrjurw7RaK5^jUoMQT#MlmVp`%Gp=eO!GvC6`6J zyT9qS!9k4O)i+%FwQWn)9)%A-LBJtzpX(bg~(`&0XK~V`EYD{G9HEi0W9OxP<&6l?x{E75pnGeWdElp8A{p_8z&#{u; zuZjl2xs$1z5k!tOsh9-*DCT*={N#sbEr1~}h{$aM2CGN7n_#C@cj8VE+AtpFpix0n zQP2k8ak9_5B2T4|H-g7`KdX1$DrSXsO>G{D$wKeniY zHZ#|B%eGg_uAKs7i_^Avh5NN)S_^zfiSIk-8)9oTzh$P&F1(0{wYp7cwdm5x$iNYo zSt%K)#fucLr@5{?$)PQ{mz$)wBVGA7XG7@uLe1T?JPy81L~MP<%uQOfS0+|F-%yR< z8}?DB{1Lqr5*bna(Fu8tM{^O8Vo19`Z1#MJHvjEQb&?6CA7^~lW%KkY1Upy+r!I`@ z32_%e(Xq;pU_-1(ieFW1>?YJU=oRDiL`O>y`5|Xg1w6m&&LogwcMvcpCmU1mWO51R z4ynqX+)QZYv%2xL(@-g=ysaiN;7wLu64^EC`=E2jWvT8KjBQC@gp(pJ+4%@uikY4Z zVc{u)OqMqrzDS;vS+D>bP@0n}c5rj_n9r2hGZ|~aGnp^)1l^V)etpmT4KX{?!=WE# zu#F+RcAm4QPK|~kg<30$33EDRkzLOt)kcbJBdM(0Xlu528}Susw=qrO4H;{W2 zO}samQX~eaD=|~-yP_@+z_mp=y)Ub#J7z5t@cU)Vwi~A9Up>Ax{Qkz*DtIA^q!_I} zPbE!*ZAQv`$3VG63a=7*sp1Fl!3Q__4BZjwt`3O%fe>%${7!o zzRxu$G>a8ZpPQ|$3a*!sie+?lhrb;N{JN(2(W@?^IG6LQEv6p7jLhpU9STy$SkzVj zQiFWcxc(igLPlJtBwtE+m5}p(+WoZXCmKZT<55-SO1DZnYa!(`Fa>7@AN#J~tL+-k z&h=H=>C9A%r?kN&@*@yMIH56%DLE*E>;62B&l@V29^b`P#-3VkUV(nlQs&8uIaAMS zW8gDnH7YaeE}YkFU^*y43FHm;tfQk;nofUGgLdis{zBFxpD{ZC@h2bE8z0)x6Kdvh z_TclkZs*C&&jq5ooiMMKJ?LbFV5Lakh%$;$A5ubObzOi38ov?D51_In$PL*)$q#XW zR7jNMt>8Vfr41<@$EKdQ?q!adw;DA)`KD>%9$f?{+EtPwob=k4++Z7d8=If{tM zWe5}7{}BH4L`A;5^#^{wEpOh>f- zv~yI1JhhYiF&>t4Dh`j{>)HPCXSS7_i`?uZ1@yA?C>CueWrdVauzYj214paX{yZN; zXyr3ywC|pNz@ebu=PN)hj2hqIC^S~!lUEF1>?^3)T`N;c(N3cMX&AQJw*u7qjRF|m z8j<=RO6ne^vfWv*YQF=!{}x?Gss}4Th>kQ90C=$6-V04aCeIZCB~yK|+n>+d;wO~c z4`8>c;v7ghRBCBDQ-Xn~u4XI^{G#=E zk8JO!*1=-mLQ_rMHJu;p-V-}1t3|F(kxfiSo*c(4K^R+^a;4ow$c9YG0e{=Z5LhX* zyZ0wKD08sWH?12e?C#ReCva2B6W_e{fZBVelFj_VPd@-xMl9+!rT5W=fSr6PP~m<9 zx5D+H)8{F>aJnZ_xOV9y0S9@OJp*3y?t(9+B{g5eCX{AX$H`?d zcU#G~3vI)Z)m9z91)4?PFd9|E9-bz;*yyrAYbfq4+Sc^=L`709K>39t2FPG;T#GcI==|rF0io4R_kyjbsLrMsyl38kMNL@ zO5kS-j3;(Z525++`6{!xX3C0Z2HvHzmQcUIwTX!{+0E;&-#PeBE$5uXErWLH{C5jMyCw`ez1JD$@AaW z#8}9CQLiUud)5<0oHt6Y@R>rwi`Uri;~1)X_Vdx*t%Ly4bMJPMPb5~m9EAkS5l_h% zWF%x7Znk%JOi)RD^7z&t{Rbpm3xn<5>N%4o?sO!f{-ZQ~l;FLIlxjAcSHtggmv1^nypyw+Sg2 zCbm|e!o=xDxHIDGb2;-^rRm@?ByDa?+zhVL>$1+|3i$WxyKnvZ(OON2^SgJj zKl~5KS}l;8L7T&z+G%ZF<@gDQ68gs0rzg8Ty1nc7LmEZ)Wp>rQYs|W~-?A|_=!GA! zolbg3^{^@AGNrGR@JL{j4T=12^C>18V1pQ7NCE~#OH{!LTCE?ypjki*v#CUGyeDfX z7g`pcU8QVg91-B8*o~+>^WnxnLK(NaHQe;5eK!Yl*+5~cRq2kzsB7=Kv9t4?XQkAc zfwdfHYKN_;3I{yunM?Tv$O%#Z0|K4@8HlyDJzQ01gPUg4v}d2RnweP-*+q#6efq4B z9xe!Q*0(lio~pd;A_v#7j(G5$=)#>o83Qojlc9x`C|j1TC{ zj}Z6WFv0t!S@Hc!xFK(|eR#}6)KuhjvvlrCs`&y&t`3s|$9;Uh?+t~b=Fh+n#puCJ zI%`T&-6pFg>d)|n%prp>jBeZc*&%FS(Bo-dq)iQFV+rHs zaK-+T$K2aJh4t*i6H#Db>J;uV^1-0%jyS6nPL^#9!xzq$1{b^;sN3Hr1&~~|o+>e? z7Ph>mIm;qnZMM}PjR|>oJsPg2!_9mkDTeL3-Sbh0+j=4ATX@^~L=ce%kS_PkTVrD$@ z{h+)g%lnF;O5iRwo`=-V?&D}m@wjGpUI87SHO1idSd@r+y?mtZYZVm}W|XZRi6M5{ z&zV+Yb{G3QyFvAn-aN#TNn>_Zv<)L6kjTZu%+GAO)ybzmR1bgQ1j8)BZ!DW7%* z%4fb>&EIt$9I+q?#i#uwkjp8?Yq8}vN#?rn zH%YIZdYbz=lsB;y&VBP7+kWtu7WA@i!ZfH?LcgCMxnW7+z-D9(YI5~NRf-HL4@Ne$ zNR2WJOBez9?{=F$3+@s8w%hvtjuB4dd z2pj`+dIC-J)an?=wb4hlQ9l3L-SwMh7#7*d($SZ0a-I7$Be3}U>cdbtUgAT?@2ehM zwhm(VoAp%w@*#a-GPZgltW?wdgmIYN7uo{OssrgzmH`hSM|KHt>XeQlcH;iLro9y| z1*sTU<3rcVl(*P_7y7u?5O44^#rNw>dEZdP-Jmf$sk1KfaRRY-7B?h();y=8nqp1i zQ*suM?K5f1N?K_ugFOTIuS_?JmvBAxo`$5kc-*H9^`qo{^wyCUQ89eZ97F844bcHoylwMc!$6~2)O1R?ZwLb8$ z_i6o>M5x&=vD&015*ygc+YqK?6%3c~P4*Erg*r_vk%%hEo64%3^-M0hMtw}57C#5$ zMu@(T(h?$4l`-7Qgqm)}d4$6A(_ZEt!``R&7sW$4N{Cz1Bj?==CmP#_?)%!E?n?D#O=Qygyg7yT#a>rm{~0Rq3(r zy2D81Vh>&64NJ{&U$^Yt9>1)wbLpAxZu=x7<}|eQtYPO}C?||YOCC$yLs#^1_Yr z^C*G;&+(2S{ud+>?#-jBW*dD75X$71jnNjz_ow0TEchW(w?k=Z=0&iO;lPeI;6>ti z3+O}}vpF94?(znai8%46w$Iw4?0150j}W_)|5yOKvb=tmQWos}!CxHBzO@?=wR z6&6pp0J+8G5Bi~8pU;|ylPYq^4QmiK$^Cg1tNgf81|g4DBwnN-gx$Ju>TERSoy<`? zn<=`mePkS%(62um?JveI(vEk%Vd;!&Zo_Z+WCtlNhSa5795t=E1Ya{QYZ%~EIf>}D zYR5{1ye{q9=V9+vx9I)d-(IZV_qUak305xY_@dksuSWAi!Ut`GY^(J$%}+8tA$bBP zvV%Ea#dA+)Qm?OaPF^Vok7X$AxdcV-Zzc|QbN+Oz-gXJ9*jJL?)Jt@4tyH!}I&0gE zWb{1*E=})@iXT_}0`4N}3K~&zCjge@0mHxmz>q(o9UN=*Ub(aX@`(j(2ABT@w6j6I zN^Z}@b2a>WLD?{hdFlb5f}F1%r}=SAouzwlv8Pn|{EhcFc{GWhk1bjBuM!yiYb$Co z%rx8A`R#HkgX8ykWuM21B*uSK|L=MnS@hp}oMH`!m-!n^O49@h3| zFKR?@;1p}@*Z43}VDjwKl*W{O7nNg?ODjW2p+J}77@QKXVsYK+uDOKAi@U>|dN#-1 zPrJ29rAkmHU+0QUU{Bk~50<<2d%F2{$9pbMOg)yLLa2&9@R$7C z*M=-G$M%$M<4;JAFdwa1eZ5}|-|9P0bCc|0Ph&pv0n7sk)r@mV^`!*igV<48p9R-p zn>>zkFy3I-Z%J8(F+yr{yZ59euw&?Mtue8M>UK8-MW(wjRI2u`kQ|b;m*YDpqHogV zBH}tm&lQfaT<^X+?jx1`QMyXDt~$W5KEcMrB(#>Q+NVXctTS|fKaWVgajQy;I;$?K z!*#WDl2WF;8cxY-Lc|*TXhxFDg`7IiHom@Zs$>dVRa|b&5*Y|SsIc|tR1T=-bpQ7F z4Q3rdWM-lbC8FkT7O~9iV3=FEZC3RHCr3;&{;*SVx-RNP|9dpku?bDs0zt)-Tt$4= z2%=ZU|AiB;!j?Y}dEMe>yfrx|UwA2%>xWABpO~DTiPjFrl|3#>7@-hCiw4>gX)oGF zMq%yxZlfG^fp2$T4W1m`bWp6nk2K`MA2jxAm_fk?ZtrTHlM;&HHOyxO9>6r2R0O&JXJ$I51)546*L3zYeRmvvEaH!!@w=~-T1V3|8LVb~ zt9y_yo%5V+rZY!9YM6z7%If>}SK>qUIUH{!<01L%Qw#b+A-YG7`4T}FyYV)*KJs=u z&9%T}c_WVY-PMAKsC)*g(Vt-CUa3($MrB3qSC1YO2>AqReSaO_HT3j)-zKn*g{9#_ zKnw2MbdA&TWCHhXTDsi3pnd7eq@zAW`iwy{KsDENQTg2rp)cfdp!jr)6>2J^Ko?v>@z?Lj=T6*k0iH=PdXQS$<{`hF~z^RTs zY3F$lQypd-$~o~*-@ZvMz?}9>tj+iz!?c_89!1K`Mnz=) zi;E6d0(8*{8xw9Kli?xz+eRn41q4%If?;7TnlH2b{}vY3`~B*C^vd(@PamCT6Mt^D zY~_cfScAWXg;}XN{RzC8>3`M#*O;(OGQ~!rKB(OT?_b`zq`u4>#sYvqEQ4tuR2Cc+ zf!$QlK>;vbv8aVTqrP!Y2)bkA5m%!q9}kV3`?dL1A7 z2lPGDa+Eoc^g>=w($5{`5qS@tdQ;>3y9=)o9kiTr z5?m9_{_3^&xoS&(E)yj65E2zoiz`2_PBiYk%w*UCI!q zqkJlFX35~kT!Qg%gQlRM0J(DDeCPoezS)0(B|SEKz6P-r#%=!6yR3r!jKV%Bp8v(N zCjK+J2hN#Cn}Y-KqGn%IPFVy`EBHLFLJsE445xBrC0q*RDvw$F}^nGAPlqdS! zO5;ZG{rxds9I`uC_zxf4^1$#%y$Sh@?Cec8Ul(I)-Ql_$>Ll1}4{jJC*kv!+VK3Nb zFW6!)*kmu*aBS-75_guA*18gcApMC;^1q!m1F$9cQ5yjLy4+fgpkCy`17vOe2ooUI zh}sfSp6jv^HuU^KA1OC<{)#8iW3XsKU|8r)i+Bg`OWskILBG$TySzCz?Tmo}+d!ix za&vU-?I-ZXX2bfm5TiM_w}R@t>~*HhCpXI0faIPI#Y?Eq5$Q}s;wkhZ0_O;7W^nXe zUap#kRr~3yM=JSp-~6ae>QCFd+}hTWvo#o=hJFhPY|$`fB-T>G3nlMd5_XUqz15b> zXDdktvdzAA#Q9drX6Lv>bmqGR3qE7JD9q3ut%La zhVml(jo+C9<*Y1t3B{iR8|7?2cnO6?_$$9NwsI3CF#l_l9VmbYgJ){^om~qfquk&n z=?**!S}&`y_wXcZwZRWdX}$FRhfvY@W)c3v?+l%CR-5(!A@6qZ z3s%K^0J!ugFh8`0nPY>?4FX4yAm1e$4q(VQ@VuY#?jqy?pql;hykD>tikn%<#H%xN zxq%{T$FqHD;**DxDbIgK>5BYpHY}uKL-HZPc)#Je5=F2mMT)h`Ala7hU zmZw+nlpJPiqB}U?mH_bfYQ_PK9?1zs2+7x*QBbyOF?yUG(ge~`oW)yqJUxwSXtVhW_KO_1h1!x@m#`HvQs2OdO6UwNBy{c$2psmP-BD zNnKMVT*r~A@UARdLP479#nyR;VlM@KRk-Zi%?Dh%qVusLsE8Pb9E;WPbm8A|mt%N3 zZGo?R4m?mG&J2WYs|N&<|tLSHb;F|qvvlsXaW;1PY9CN zGgo)rQ)OGnMhcVTE>C_dfr5g%M?spdgbbpWFlNo@3&C^?cUK0!WEySaQQ)$3c~XA? z-cJ~eN?t8a)VPC~TQKL$Sp^cM7l57AMgSdDiVbzsKz^b3RYNDBUH}%F%YeX{W;_@p znFKYTOaReTN%v08FXmS|uFE1LyNf9-CL5*XWUHh52uQHN{`#$b+j%kh$vuGJvr*)E z@Pr%t2cR&())LWh=4l;^AtEZ#_yASx36W=h`8?{!r}sZMW~f!6(&u{zG!Fi2WrUXT zmXz57c~RMD?l;s|g#*A5T%a`TfMuWGSB^9T@(LP*V20~lv>eaLq5l%4%in^c@5bDl z-wkonfb6hZ&GbU9kqW%%Lg%Dq({}_b7b?c^OXM2)0E(naP=((OPnqC;5nk;8Dj2mz zQ&n&uW!MAg)-8)sJ_G_+(OUs~x%+_L8~f-1ZKrv4F;N5Gt~JN8yJ_4`V;7%uhTjzq z7w(OiR@0VyyxtrO-nK6HkWbB03CJD3P~G71c%6DK=eJ0mUNLOyUCjxe;)}fpRSX-^ z?zTv5L~hIFjz=xO1Eyh>tve%w*?aLXo(h49WAuVN=uUFq7M<@Y@FuKbyB?%Hi&Hvm z2yCwc3d6zgmo(3?D6YvAIrnj%;bK_t3>boRR7q+v{}I|6&+ReuvEIle5y%{Znajrk z)M*R4j*CaYO#6_l-r^xuV}98TI9ErcL>? z0@j7vw`l33Oyyl#7O`0s#tQ1j*$l>*)u?jn+(fp|@+ycF)KA~wvQy9Boz3ZSGHbqb zO!+ez=rOE!ztMh~e8pj>{5tMe3M&7?u8VxKJR7M^^-ThlQfl+L*^8fvbM(U~y=gC2YuYVIoS&&Q8#lsG z&cp+B9FF*9j9gzI{DkE@!>bPtH{o0P26A(2i~6M=zt6Y5rFMj`cGMDA^E&2N>McK} z>%oy=a{O_XY=T)V-&aHogLI^Qhcg|?#o!!&J%T2R^Hg4GXI5J~lhQn^yKKqcwu&4E zYRU(f(}yo*CH>*~PF2~<>ggsM{gT~DXOeY5u6#b0Df;vDuC4rZk>V>N?V*RhgYuXx zz~x^Id)8OZ;p(@``hf6-Dt=AOj-xi?kFbV-u=w8$F#c>dN}kTMiDDX?`sjD&>|)r{ z_5yzdZTzHvaZ@YB?^NW>=djyuwtX_hL6-jJBev~?m5EeOh7|ek;+=AzZuiaCQUn|` zE?-`-2IyNvMByUoMzy%VtHk)ay!+(^9iS&9x9Y2+0Ap0UbI-DSD{wH`#LD~|hLY2C zTET9TvrLihu4a!q6D(Vs>=>5zkb#ju|T^ODbXj7{ywLxDxkF;2}AKU_tf@lhp1xo7#53t)`Ral2Flf64YtF@@`wd*zZ53{Aw;l$zbgM4e7m7)GPpBZ1VD z$Jc0_SEvXTb&&cca^BOa(#xOw z6)&=~*`>z5bI5bsqtFRkO`EYY_VLZ<5}cE?MLEEFz_c)@>)Z0#bn|LrdNTottE+p( zF2xk*MmTlWUg;0fm}pkJHx5jq7uk2&6_|7rO>^{AR=2wibAFlP&$1*mN9%)DRI}uD z#`5&pdw33#Y_h8zb!MTbw1**9J-_V}#;Hn@E<2)e-Q^ilGj_6~gccwdtH8AY+c^2xE zFS+c7Em7?hCM-UAJyWC;YY@TIm(ZV|W02^b=-zfHrdV2W%9;M~1=mT_v}-!|F_93&Rc^aAUDvc+{XzgqF7Sy$IF8A+q^sZ+2*Y)S30LVfwRa zjMg0qMnASH4uqNg$((AkWQ;#i8_N73Ud_ha9iMMPo!*|Q(*~I?OKhWFQ(OCuIS*)4 zBeKb4H^LuMgdzEMPv*z{#hMSt`~z{b+4(ob%@diwBW~#E{)V`bD!NbZ@lFmC8)y-I zh>!wN^I@XWf_K2XGU6wY&N+aT4qzNW!02-z>BZIFYsyb{U*%2m*Q-GUQDP z*;La~R$BsEB%6}x|JJqyKN5|c~-1&^<-m&Mq79j1q%9dTWe ztR)4FioCxHzs(ub?=St%a6qHwx2f}|@XLZx0DDH*L1W@Crp_t!tVo)KuV zWJ{NV`QMuUG-B3%TQn8zpegm+&WUtxc>RYF!xZtSP>z)Ghn@59?HbUO$;sdSW#s(d z>>AMI$z|Qc3L)!Eq1&8vi5HwjQ$M5*uQ6P{CGEHqwtD5Om_Q{QGNS#-jBl6}f1u(= z?^||4O92d6J(}r?@Sc3F@E&^7$X5lE?;*ywe`tt_9FB}e%c;0?G*oZKZP7V^lou1DM?^4N+tBFAHY4st^_Grj31gns)#iM<4q^7 z1{lgoDlVWB@S-UR-ix}Ez4t)NfXK1m8w4os z)_jMHs;B4z+Szm25$A@Xhb5!ooYXRDEY^i%3=P9a#9$yMD z^7{L~eZyT{wOXJ2#RcFx!7-LD7(JP^F54@6O#KafYo_}P1PB^z4pi00obAS{escm` zXbmUF8x1BSY1&xqHT$mmUi{()2q9K5Ib}JTdlG&W8!4Ows9lGUo!n4yMXYtXy%7L7 zw4VTW)JSU$)`(nrwCe(Wts$rUXYu{t7P*QEKr^h+HlIxpFaUi_JpSRhhxf)V6r)A( zCj?~uh!L5&xw%a{p$Etg@3L& z=*1au$8Ia!>+<30=enI-RllLov|IV88UIOm)hKVz>Vx()1DLR_F#2Wu=e|`PDum~P zR1;(t?rK_!5u-L_eM!|C@$uHmdw!TsK}}#R)9+PIAkN7C zqn00Q^fT>NGUF$u!2o+9`#i5KOi}j16u*~|A8Cp2BXvCuB~m;*uzzl!CybYf(p8Kp z@KrCtDAl{2H%dg0*etaeQ<{As#gB?tNfd`TH`Nc*f_!9|d=GjRZo(L;j0e;9eCCQClA=Qn1y4U*8Ax-SalKxog<^OQR`e z4O_N4+qGce3}BmXv?*X(Byf?eemdW&i}4>k;D7QyU3*j)eZs4v=;z=E*HA(awrxb; z0|t@)*AeAa-G8QzxV26VfzCe9`># zt9!Go01yi|sTyT+gCsGv3+DkfG%CQD2?sa9ciD5x>{QPUTf9+`N;L3p&BEvng)31w;t}g zQfS@$6{{GVZN-*MPi%^137jVbb@ixphWe}#VoJjp%5+{+18rH^QN5%yQKJA;llP1) zFA3v~yMK^?Wfm1oAS~rNo-sPpQ_=foG+YLYsBKwu;6mrtiLE=sc)g}bjU99xiCr)) zp}#c47~a8LHIl-PPLQzEPTkOW`57i&57oDKr=QtRw|}wC42@Q6tLm+J7}U3ECRcyE zICYvBuWXPi86C;T>Sb#BV?{!6K(=@0QY86PVXiiW<7|ui!uYDa%}7(zm5!k1lvB;` z^hgH`7ju`Ky6_n?)mFl+0FJJ`Q3WOkL1K48HS`Ie?_5HTU~XEofNJ1NaHl1EFu*W; z1A7~I3#^H=7Xx1Q<)YPwEo^h#z?yt(@f5IAi#z(=S`Uxh_f zz<~jM7Ogj@fKl4UtxG^NqI3p;lA=a;J%6LMuSI<4$J+!i3h@vBlRtf^c#ddyl2%sU z=+N?CS&VElh$w`x|3`n9lGsMygRL{JaLPv+oz6EOcXa&^uP9VfNTI!DbGW^9DqMhr zsVO%$n?%S}!bFrr!qHE9(r0Jqy6h_P@!+|)KCTUNyMSE+s91llC5@4UZcS|kER+5R z*wTNZ0=rO5J0ew94bXWnf?e`FDF)K%@hRTETFP$DMmbLKGI18;Wy|2$nSM*cTD+&M z#@uoC+q0>=I-R}}8fLw1y^YZ#oyV2aKbjF4X{9h9W~aC3^85YAhWnWd*4*M9Z(8nk zeb0uajZQ9@+ybsOKLi0!K8Rni2A4&&FCSvQR7M}*{4mi$e7?omK7BJ=xKVr#*0RIqS-d^nO8Imi!6xC%@Dh*-WO4JcHs(m967J1LJZmkNO59Tn<6xq%~$W7c^G` z%36ocFmrpFj>-@4zVa-CaSTm4L8*A5?&g_muB-kY!*f4IqyxwnS$?nMxgUC6_vG~) zmbfHiBI!NS0Gg&WZYv~4g+<`Q3Ejb1kER3z(@0@YWz|+`%b<^qT()oSjLvH!&-0%o zTi+g!+}t=Sp}AK4#XI}{V(4LozF!#wZcxa2E}Ll`K817s_vsO49)ZjZ8O_1C%T!8pK`mx%G@%GKP^hW&@DIdC-T1!lHd3B}i)1Yukuvp;W*Wgd^`maPEz4DCm@-vU80D`G~OzmQo?tOCwIN4_6W_imU`zXhp0sfuRTx1ja&yks`(+nZ2ctx+ z2NXgjOcXlXP2*n0XS#Y!7Y|pob}HLYr1q*P95UE_yUxCgH!t6o2>EC;CLE@=*LJh3 zPNKPID?Rp%PGCPoXH#*LcyjnsBntDh!LPyk)az^g)?IKglnq#}jV zb}1BXmJyeHltW2l?Egyqas~`YHR&J9d=j&JvOA`eyc-$HuT6*L>I9xxN0~kH{ZjSE zBd30^frc;|Aus-YXPpxrh?Nv>#$14O1EdCPc!5O5>=p$K2k#x5 z;`M)ApVTS(?SO;isZg~udxh{RCM-1hI209jzc@YqQ?2csnJXKIw@>4XL6tV(RJwT6Gtuc%&4QJfv*ko zg-qM}rAW)##{S4r!>k(ai8~`cc3J}Xyrw4ku)^l#eEG3c^d?Z*u&x#ANfF%&Z z^VcoHOr74uFC1q&y)#LKwrk}sC}CpcE-%H{b$UFnzc7gS;v#RzM`@J!-H|E#iH;p# z4Qirm3tvHEsP^ap4Lmlx!oZ;KFb27#rx(&yk{ zH8bB#xns1WR9+~!UDgs;%R&(W?|-E9B<+f0nV8(7oe^k6NuDq z?5NuQy*ly3CvHow!xt|vHs%rm1l5fl`{PglnM3SN*C2y70l48*u+*IJ@VN4LmL9YrI=6BnEP~%Y+ zi`Bo3kttLYHv15wwRRi!lPxY+Rf;xlf4^)l9}R~WwE^p6_w}+BZx#q)6+>^B^ND_S ze{4`#gNXA~QLZ-DOo4&*)acBUcY3IK>#Q|;c!X`YA&k<&{(eCnb?NPI_mnH_pI1}5 zSmRw4NIRa8H|KaBha#eyJnas% z@xl{9{m|Lvs^M=GY&T|KV7@;+2R6YS7~J?1$q?Y8PyjIJJe^)pONTsf^yt&tj7w{} zOw^OcGa{JP9_7bg$*W`u- zjAz+6K3yaB)I$r3-Z6hfXxmIr{#+4SsO!fn&N$SJxOi)=F2nTtf^8>AI=X4|v+MB4 z3{F3qQcqogOLv>5rkc;!Z_*SjOBY;TvJ&q;meC!Y8BTxhqL_(bqdq6Gi8qU9oDc~O z)+xWWq1&PUt$w*#i3ld6!_Xxo?)mcIQ&aoNHN=zn?+{P^pAb)LrOw$N=P+>~FV(*l zCnoMWK@Pba|9ieRe;PNt|V) zJ-l~DcJfnanGy@eT-s;7{Ol8$p|va#q|z~$>EF?=<>IW~G&ZR_f2Br?d!ATtVmR!^ zwhel*at3H5d-cq&vSGdgNCNNHf~nU*NW*d7I_~jyY(n%Gy8(J5riX_`CaBIzpBd&I zGzxKswYrxC?DTiAGbg7J*s<)Gu|58l>GAxbI7-#h+G{)G7JhkYS!P^b!P{q(9Rwq2 z;kv(x*ZO&vBz_XF#ndK7;{PUId*+Te_3RJgwUJ2Dyr0BtE~H z=&~V_Ak23VK$0$vWx;uLhh~!%_p#wbsAgvOw;a|Q)|$&gMFL7P3K>>ZIK{*0lG$X{ zeqEA9dLseZAz9nNVM>|@5#mikFCG+#D*A|U3*MK>Y$+6rHn!@wHmHf$*0_(xX;e&^ z6y1ZFvBBd~7ZTRQ<(kHIrt7|a)%`tK+Pa3V%aoiWc!V>kZhYF1Ena{@FN#9Q z^kuJIth7I8)wU`vW?5yF(u5vGjSipJ`mTe=2k46UL;bJECj1f6-sm!2vbiF>1>GAY zA$)y3Co9_sfd@ACd+E?7p58hg#WU|)I^?QJbdjzAPcNTNp(sD1jcD{%Tr7uH`8tr#$e`Sz5X{h5; zrcZ3Q--U^J($l~vY!Mewv@#Qhse=+t%!%BM(be5}l2DzQE=nPG+b; zB5F=+8*F&WMb}PPiyt)RWgz^O$li|oaLbhEXo!2+7W)L_s0KCk)wDkB)*dh4Vr)2h zHwTY3(yVy`arckp5+p1mdKurH68XGbj$^NrTKd+c^WsE8cX5xph@e8mX~A)WfsQkJ zwREQxh0r}mvi>dZlKx*V*j*OF z?OFY;AFCPT#GoD}j6_xy&fgvn39V?i-&%tz!9?9l>GGZQ9v z$;U4TJN}R?#3>5z`LP=dlivf6@#3Ty{PJ5=J17M|7a9B%Awq1EA3h=k;63|*q7EpI z|3#!L0Kn6Vz|+iy$%DYFq=8}XAN0DGrYhoVY3CPq z_P=3R(IaimaC*%+?w*LkvmnbEyiI@8Fub&yESkS(EQmrEilBzXdDCyGsLH>MYgnlj znO%!coE@4ztXL9utc+GJy;*LN6Pga43@{J7GC;@e!CTby#XQY{Sv(Isn#;dj`M7a% z6jQvu&lS%EH+hnTm6Ix?Y!en0W)SSy6IK_OgM|4^Jz-v|YZ8|q(?fRev}+`Y(u{B* ztWCNZo;P{dEFkP?3^&uReljuMn(Q2&JHy`;O^G!d(P2U$ZD|oTFSWJ5sTUBjt)sSH zxw=V$lTlXBilA40yI>2a)AwWb8dX_-_ndI+&14tH_GZsFRl}nzH61R6@+U*c_1XY; zbnOQ=f%(gweNB%IQ8#LJx!)sU~E4-6CIp@=hJdo8p+Q0>AlDl&C%I)qNmaU>A( z5@lE+2dJqQ^sc*^P=9{*WKHYj5;9$4Et~8DbN}89&k!0LdXki}LOj|)9b6w{ID#}f zx2H*K%i|bdaC@&M^@U^N%G^vX7TgcIr>&h2^lV5Ry$oqv=`4vBH|w;7%j;Kq;7=cL z-&MPP<{A0nvhf8l5Ui^H!-Hn6ob&|=O6jQOWbjI-2S&@2MWI20vMeGscJ!-L5-&)! z-DI*Vh0>5!zlG2x4~_S!9kb>gI|wVb+QZ_H0i%w`(TBuvZ)sf!%#f-~Y>SHoYIcu+ z$+umvxX2P7<16Hfwb^mVwb2pN>tfd5oOvh>Z%w6f!6d!HDIA=Es9O!??%Y-e^d9sW z(uX~|!J`J~J=ZXLy$wiRr~qkxSfjwWz$sWe;3-D<*8iFHNr2+$pP5q7%`K>L;=}*< zY&`)yc>RC-jBG$>rykWMfeu#uG(o$EQP+L3W3=K}*eF~S&a`w;WoK7++_t!SVR{51 z3QDtK|J**nO(3>p{67>LHV0tPKBtF6cl@Nke*hj*FwKmOQamMD;Vqdt5Fa-QuP9U4 zXc}`uxZf9S*rM==7)J~6s)F1C?qY@wfTiwWT2>)6<1{U zW#b9dPw9*vGb0R{IrE|+L>Gt_Y(p>wxfKE3GOu-j==QZ%1k=Up9~mz-*sJ!Bj01Ta zNOh3^4>G&!gPs0&&-eh>w*KFGJmB-S7+=4F6a(ECv^L|!=^=GWwwFy*_BBaE=U>0_ z(T}=@72iGeGyxRqfbOBv#EP-;6R68O8K8F3eolPb_X!%6s?p~%Kq}1ahFuwep5h*j zd(nWQ0p4WXdBQeOGP^3>A05H4%A(ESG!x+J*Oj>ehspRogau&!0^NK<;!!6q3uauDh_&=sW51Ei3fweSqKGPz&=- zJeML_J!M^PKGj=QuYvApQJTahmItafFQS|Zx}&xh)}x-UGEjwQo#np1b4l)y1ay}a z47t|Sxq-tY&^~b`=)-;%HDPlgmbgi~KRUhafwmYjAbqeCA zYQ3Z%mvc@@l5RamJ1FZdSSr3PbOdm5J8mVC~MnBA*7h z;dXh89K*iC5>7=?J1*xD_gGZz`$DarxVcQLGOiV=*A>7VlFlUD_0ars!FEAvvVpV` z;PXN>GK>R$Vgrb|)-~)Mpy5z#s{@N84@mZA!Y2T1XfQ=vOrANjC)6vom>d}ntp;WE z#=nZ^is7_kw3tD* zgO*xxWBNkJx?>5~3cw-@_EI$)0%ApVIpC*(hFXzW5%4pX`{;b1wx2awK}*`iRRj*okgA8ysiJMm&THtAWg zBym|&Jujho0B7TODw?t;{7bnCfZhv*ad00Zu?X%%$d9m^|JIiR&xF_yAci`@#{o%( zhfs3#K%lCyP}Ej7wfENRx#j-zuZb?=Fbu4$blLCKKOTBZr#PWbqb)L-1(}n)$voLE zIm4;+E+_LwRJE)3tpIieKS$qa5FaZ6bc21j-FP}+pb96=hSWz+rpLd1+8w<&gYgoX|EN=I9|gBz>yvYNd5(TY^sop=034QlWsF<$RZjq!t=M^tEU8r82(fHS zT$ps$#4b?j zZ381!0|JJ4w72^5`z;Hdj_znj@jEM!h-J4d7}#X1j`P-DjMGxagxTj{tXE3Lbc{3Di7} zZl1f*PfR%W!>;fOPHL%&PbF{F6XCs3|C0UQqDd879u+tjVw6uehP%r#Ov;ow(!z_v zxa&lpyl^~fq5XWT*ng-sYdb(;l7PJ;*eQKnaCQbs!O&fcAzCNo-N2;Kxo8x7h0me4 zG~NR&*cA-FY6gN8kE*-;JRd6ofUE{rBuhlLaKB$d04Z$8cVVQAO$ca^+YcZZ0VOiM zc&(?|1O6y=hnpy61LaVx+WmaxPzb2ovTpZ=R}>8*{33O;TFfHCURJA|^o*Yq#P`2S z4=OB%X{b~laBwEOPg%JI^{!pM>lpncg!{63lcjS}-U-u-p1HI6P9;CG>e~>c>&)Bn zBfh?8vI6Okb1c;Z-|z#{2ySmgzx!mzdGJd?e8BU7^!hFu?_$<1r{EDgd|<)1>Ztj| zf`38PhL6!3PJr)~Vls%Uv{_y(kCp3ETNiy_i3&(yt`xVau9qk6nx zevSodRZOFc4&Gq4XT=+1Frso`sD6!j6zWJUx$Zp$C=$~#=kmdXE+LcP;5FH5v%W?6 zZh+G!0iU|v&qfz47!&b{Gp*VUgFY(+g{LmPIr_S;?`n4|_u%en5MG2;ZN2N#%BZ?{ zuT_GD_>!A2ff3Ol_ZpFdb3m4lvqC7MZN%y5vzOw91Ts9|_8 z7$|13NxefUtNg;HX5VYv{L@53nCSi9-gX$J!P4jxCD~P=@r}+{G=pB8hU%cbzEglF~YW~ zowqPeqj#X4w->LIrKrHa1P)Q%7vmFPn|e@*LnT6BQnYuQ+Z1q0Y??1Zpb&GzZ%zX7 z=gZr`nG{K}xSRXIooRF7kRkM|mBX#UV3jJn`xM>G_Z?3YBY6+Vbvm{^+qxKU@fgMQ zYnsdA3B?46#35nTWKt%Ybc@DsI5t>3Lt^7RSSsAQ#i7)SgIkhaL}6XQQh-ftUXwSY zKNLc}y`@iiCz#i}`Vg`!@%{>%5)XPWbz5fD4a-cT2w21I4U|?oDs<~gtq7%W8R7p_ zU$Dmzj8&z(cNoj$=IRcw=>w`SdM}=_y|yUIgBFc_+Rmok z9BLttJD0q#>jwvAZMSY7ePt_}s8AVP`0m)>_vQ{(-g1stY$gj<%g*p%d=g>AM~&Jo z)OuI;p$RPn{pZPKdUVPN&4`wyy6AeDB&mWa>Z(^!s`zQ{ctu|Ej?Bi7)SNcZOvJQg z7PrQps1>w6kVLt`>U8{)Jr6J{y-cU^ZAb}~wmZh|mW-mu$VBIapBC7y`F3eNsoE|L z@NjR^DIBx2pC1_*At`|`^ylt(7RE{)qn^M8cx)GD;j@A!gu!MTMZ+JHwB)`^u9(rm zv#)&P0+R%eF7VyONFKYNnZyq`xgJ*FCw8p3Wppk-Gtu9l5##de-u)IR7qr?@^W>rY zqfGGvK{3luPi1!nEzoCrKWh|qGFBd9cdVo;I~*jyLR=^J9m)#JkzT-ea8q| z>EvVpcd~hJ&jSmOnm=;s+;$bzQwlMc8PXo3ene*oI6eAWtr%vf>`|>0H^3pQz{Q!} zVZiJ(MvQ}e?JShFF}RG^o(8>rI(}=ErCuiQUTZO>>96LZB>T<5gwPui?85=@Nb=R8$2Al;{I7Pk-arr)ND2QT%1W7Pb z%k?uipyPJ6_Ii;P3{KKB738d#Caycqhc`@c^F^gV4A4pX1}_GQ&7!f5H1ktP^VST= zPFsU_rq_iJx6;k0Pv_R46EmS@Uw$kUnGZYH5+^}VlX$sC!y^Goh8FD`E_P3Ny-6J{ z^PrSV&?y~Wdxqm+%XziC(l&8oIOShMjg$Uv>r?g`u+L2gtoU|&H@FCsM%>`j0?g)l zv{DdbjWx%J2E4=S)(h>1!;YS!*VQ-1jTX>-MjEb-jZJM(L4sqnKhi?clj->Uwjno@ zgjMtrcF?^IBWLW5e#4_SFVY>@(r!e=A-Gh9g!bbqLM$Cqq_@)4=s27l0uY3K0;b8G zXm4rntgv;?>ZMzi+}$fVEU`tY*tjSxO;gu&C^eXZpFN2^q(LK>@b$jkCX90fvgJzQ zY=(-!Z9RH&_o`wQ$lb@Y=!JU=z$9G5#U4muQ*G@IpuX>Sqr@G}Cp}{Rq&5ZooKF(Sz@e`)ZG;oe%~$lyd6>QJfEyVBZ+9!(eV~S+D$)%C zl^j6u0R29=CkAN?^C#yYlUwt?A>hh*`3m%H3b5XhUl(WzXRcbZ31IR(Q)OE{&A4_L zK~3M4(m8Cx-opUACi8Z3lN*hG2<^}6`v4Zv2L$j^(5e{-zSnsDP2Rsjbn(tNb?uQr z;kt`xzZqyE{Hm7+z8iU4nLi!Hg@`k`Vi-D?S{-qEMaYiuF}Xq-I=^hAK|1OkFF#P& zxorpp|8x(qqBPoELTqYvnh>%kq&tAL+w4a;RH2nH2ml{f>H(>5Dg&{_MqyLCXN7%P zdKyEW{>$qEY-2vmc46G&wRknzENMbWmu`_aY@wIF>lc|SdM*vknSv&VXEWUia6+En z!BZ}MqUwXN(}moTU`BIt@2h+{uXn1w$n7Q6gy7pgBQ66t4|q&~ncWgO+Fd9>SPPC-ST_Ch-MVPGu>4T0`)$g>}U-{K#1T6t-`DO7vfA8shkup1&RT zPl6^ti~YT)|G3IOb^qo1fAoy!Qu`F>{Q0qeH2>=j{iDbAq*bQc=L}KXj(jjR^{p;t zym5SNx)Fp=!nE`4>)>lpgfbn2N2$w{sug-}+v)Hm6_^oF*Daq8U%I~)<}@1U=_@Zw z+;?tYM1z*N=rf()4w!QgshvC(=#5Lu(6lxxh5+HDy)UBd^E%#A;qLLa+G$O8uI|0; z$sn(++BrW?9Pe^(xOvj3``{J)hfHBH-UJ1#8<#$YskNr>Z3y7#rIP?yx&zI}hb}!T z_UG}v(FpeY{d>H9B%c)%^qJZ_q+MsDNv#SB2ip>~;z2*~;7>oW)oE87=m&nv$e=ny zp-~(c0J?$k@IW{4Xrg*<{$I=<6WOEawsiN*{FdC{eEAYTmiQ6P8NKF+QRVhzN_2XS z0;o5=-0Ba401Yf-y+ldAY9Gx+RjzLHByGCw)!p-27p8v7!uc3q*pklV5r5qucF#$C zGLjzzZ6fd5V~$96n>w;MJGK9>>>i81*gbt#}lQJb1640!N z>0?Ob`qh>vTMe=srXfXwZRy~@D@J3aVWWnJ!$uH&3Bnje32obPbR?B1VS9Ra|2)+uP6LL2cA}FOacFJyKvq52HFCOk&ja=r2h6`8v5~ zGh|YS&EP3J3DKyS%O+VsG#K4nzqgFI597${3ivK@dwI;!_UwEml@!>U=kmq$Nz!v) zd!K*UX%vYHLG;Nb(W$?>`|b!p2j?{zZ}e9&rKUP`7CM?IcWsT_J12>29F<|eeOK<+ zzAGy&!ad%^Am?7_y=?vg@7{yvkHXh) zlu9?kB0}Wnd;O~@u6&N6hhSdq-;QZmO-olpRW~K`$bvn)?3JB(!9M;4_RP0emdDiN z`$4?lsJYOs&|RCy$EVF`0-0)Vs6IGxxH87aeLCD^VDMq=k(i8N;0oJHahC(52$Xe8(#%a?S%`0 z)a!N^_`pyr$`X_rL4A2ya!cJht4Ww;6ZNk{AUNU_kG;f5JNxJ1v4%o7tLdLdIY)_B z8Iyf)aiUirdMUr^U~kjC`e2cQB7}bLrdn3t+>7k1sFDEnw`7ABo_^w$NSx#|OMxH# z>R+Y@=%((a?-Eg6=%yB;Wv9ff2l&AcSlb{S(>w~kD! zF>Xhdp&p{sA;EroG^%ga2fSju(+dQ^(sqo3kF-eU53V4-*z)VZ28L!2V76!%3xmcl zFO9|CId}7z#04F*pzf_NNoikgTzuWbT5r6^2`VW(F^rbcUr%bg7pFz}CN|rr8D{*l z{3o4fDUgFb^mH-@G0M9B599>Nr=wV2d?aE*OKiX997xfzyDm^v@RcIa-VN5l7vUEt zNal(|@;GukF~ap5Ii9i`IzkY=!fToij!Wk9S7{bq;<~`&^n)*}qaO*XH5{C)FC87K z(C4W`R&{>xt3v}P61e&_l2sm3_AhwNeTYHyC^O9NG10r{VUM1xFQU615=M_eA0bjV zcmQzf^#!T^q9&RCgE3qw5E$53X# z=;=ci0OjXIQ@EGV)ZFnd_;tn=am9kbt9I$mF-05-R*0ANs$CIg%pom)-N=zs?djpBg@;NWT?g;(I}SSA$~e-~S;6 z@AZZ*1D5Yl3l}y7c%y;7eg0HZ`|$&aJc3&cM(atBmpE1mCpjZ!F>aE67t(Z!jnO0P z9o%cD9FL391G}NR#=8ZcE{*h=5JT7n^ov3q)FgrQ8$7(!UZN`(`pYj(1{5=Q+Pn9} z(Twm=6lCn~5NGhF8;pfxT@Cq{1{yGv@Z@Ozh)W1eSy_EO^E?`;%axNN)0CZC z&Rbf>(MEnE?a*`|lT*|9u;-de3vbwyJcAEr+^x*?_IRpsx2d2@&1O^^tUMkxk^_CHNu#dL}*{*+mdJ z4E7NEF1=QbxE$Z$6_>vzj)^H4-sc>%D=)I8^+O>UBh(R<@8IUJ--G7)LUz;Q$8TDW zbHAr)q%nx}g~%2G#!5Ay<&o<4i|W1Kt?#cnG`4FKWpQ0UEqh~)92T2AwSR>X#$b@B3M{|Z^CH9ghLN9RX!N>^7gc~lWlmXHZ^s) znu#Z!z7?&69yUYCnX5C^d;7l9#L5^YV@`>}g)#LufkP{DZ?#!c3(;haS3qm)rIIe6 z0nBPo#7Z-;v^BR;C!i5Bx3T!Nk7;7*WPIc~wJ85$G+7L)Avy5d8H*JYp2LxT_jcM- zdqrPiHHkZnHmPMg#y{awMY@-3Dif_6_^Q=iex}yoN!T(xCMmzv-Y2k_hgV%*3GZjm zwdt--$H~{VUaGy<4*kn8(aj@i^3G9CSK9VYeZ~&P=dj>bFn?n_nC`J3^;60s zIm4M?(wq)EIe*i>a{;b?tSfL8{JFZ?cm96#p$#5)F|D*-|Coc~J=*b$LhU%|m2~wt zvR8rn^WFS=iiuc1{o*@Stw%{0{`D*;7|vxEX?~r>(~Y!;*bj}9tv#+BB!Whxs?eN|q1=d@z= z#5$$>B`IIp*Idecv+*_ov9)+{u{G}{)9YVZZ46?2_B`1%tnO0ycwWCJN7w6G8I4r( z?=KkPf>rux63WNp2{s45w8}3Fxz>}tp?4VmF_vwtFR*w|9hgiMySmo~6$8u|H-AgOC;PJuz|&ObiEh#d_Rzb*72HN-LNujI*uXcQz%ju@ERLAUmu9oVE0rr}1zRhz zYaB<=9HTZgh0x%Y58Ez~wDLMHHr7edc|H{gJt~x8GjdbxRAAsGQ?L^yfIQ_2H~!%% z*G(pLPSIcXa{-^U<}gq*IMgzdZ#%c!#d0H8PzUN&VVihZ;$@>;LMH^yAwHO%Ow&9G z&S!kn2%-I>^^7=Yos4mQ?1Ax{ZegO!I1^G-?}&SA4(vDGqLAC9!y=3iNuz-cdSkjB`b68hm!yT;OHc#VCO?=|*Iv!Mgy?!xRD|EZCD- zh*mH$A>o_hXn(_kG^SLzQ@j!I*T61}(r?AeL=3w9JuSb@ez;_{ovzZv?vDLVH6^xP zY}zkL)J?9&th)+F(630S#?;5i{o(nkh;%(Pc9tYKRn{G=wse3$b8)Q#_w)58tS)LY za=gRD=*B>G`Ob(u8~Tvs%oWEOX)kO1)p}@o1JRbWSFc0k7z00CZ_DD{S<2s>nF}|- znFU+^3(oA5^N;b@x>hm^ zH&gq8PY-osV&BfUc<6!xcRq7SFCgZhwf$~+v{!at1^&k^;1Ixj^QNw?>}8rJ{zg13j^nGVQj&uK6A-bs7~ zufK4F#(mmoW<-7Ko{Qftss+fuwyR@!i`TWWKb$7qY@+^ckQFkD!n2;2q$=Oqw?pM^_&FBE_ zuMBrO4R+-gR3>2;E1a(DWuM&3sHKaIWarH8rAtSbXh(0*y$$B2y8imJm|V9wT2fns z2H>h1qLaP@tQCLl6o$X^%@pnSVml6YS5db2^G?RUHf*}Ht%(*j^|NKUzTfa^ zPDeJ#OD;K`%KNY*YEISG>6!guPVl|A|H+)FSne)E+X(X4Xh11{7{Om&{0|Ov{f?Jw zw#*>^xikG#J%L!fNEF}t`>AZ0&3j$5*cm5ms%Xr}vvpQ9>Nq#25@rDpIv;;*oHrDEOpJ1SUX|~g)U5T+cHw`}yPp^F#b|uh*IB|F z`2V$GjH2=XHjD+Wmv<|ZzY+I8+0Ua}Bz9Bc|ty4*)T)q|2?$!x{(}gNO}^;VPZ(F7{S@`XPB41R-uii-Miv=k#!L~xa=N|(TE>T`$XUO^L8jHWs&NE{fb2&+s_|>TD04<_ z$pYA=`Kx%Wf)K(pF>G(wOROy5(6q(#O#E7RNroO~W_=JQY+v#%7h1l{eH@%J@87ssAD73=hP`sx`)Po3EOT2-h}JnZ;}Us!ODpFTQYXYKXV2eHvK zBg=l`?!R)|W6N|5N4Lm~3K4}SOwM~X4fM&$9J#nO$;o|FbsAc?tkT0TCaMUBg>OmK z9NKG8ji2YuoyRu~w5Y*68(yq@xsTt6-sp$M9DRD8rxeh5z4`i3o$-BE;|V*Ileb|# znLCz?`3U*HtoZ-Ag8rBP*^C(rr4?NNSBkR_^ZgF~>f;qYVxH~iquY>AA5~r(u|;mQ zs6!W=mr@&olcqS;q?R>GmjTBOOuD|Ibg@~p-l24W+|ba(q_av_h(xEe`eY(oQQ@AJ z#PQ*=Fo!dE_d03zEA$}ibvEWBi9~?p0B0Y;mz2Zk!|bgaARGK-;6to!#~JS%Ao#za zrie)?*xW@!hfR|~^D7)ty|er1t3=fllM2?jcztf)H2M=K9WA=}dsnrGCSsVYfw5Jp#Wl@#1st30 zpNQ&a-CM$OGWWC?1e$f~AuKoqw<|;A8IY3BHupQY*c|NVi0HqABlVO2GgJcwhBfUmtCQsC zmHANFVBM97VYQ7rA3R)Baqt($!%y(dXOJlz=puw27$n3gzn@X> z_tQY-)|06$b+6e@f91NLJUy$p$InQ|f+Zu7mPAkxr$V^?7oT*AyW!Pwav8tgsKUqv zzk$tPg_HRLeM_by(BuZXkU4N6XI-q~wz78gR92Ydyc~X18D}sTMHQUz8!s%VRmqPz z?O3+s&_=ihANmZ|Z^R5fD;lVas*K;!$9t z#bc*vJ!+nx53dYgVAEpj)E5r=P?MYG)()PS&e&7rRO9Hum`DShiyos`Kcj3X-Hv!$ z1&MM^#SeSz4PjkOjM0o{h4+o}+IOj%>iffa9wIM!$JsUY0s-{GSGn(U9cu)F6jcN3 z9C>Bsnlm)bqJ(m)uqY2_zo}F2AikAgP7w&Yx0x*9qwC+u9Ad2&$mPl&7H~m%mV#x> z^vjJ;hgYIc7UDe1clEq>$&9g@OQoxGIp=a4kU}hIow;PjFCPWAOZg+;Cdorsx`Scy zp&P*cjCCdir@FI;8;IZp_! zryRDqsj84MOz=GF#O7O8GqcWt($+$65Q661Ky*>WUI4TFc|wN|+d6LQUqeMl;^xGO zx>9eh>M@-0%lnst0jCeD$>7f>Wryl-4iLvDOv98+n}bqddj{K1Lm5oRgYrn_O;oY# zRSNS}oObJpyh@*XKQvN6`AGYDuTE*~A{1^NTAkOt-^{v?Q@A4pzonV3rFVRSplwJp zamiZ3cmo{RUS4n%6*^0dm5-2|)DkRzB1P<@owRLmt%?x$|E-6_{y7uTZfx!H9F z-%K`s(xUoI3|Yt@&ux=TXNJJQMyVW6rxIDCa_cyzVpjn*J~h z+Q`5*ze}0tgw88vPu74nOoQ?8_vCeDVc0|1vtlU6xwu)S?$H~+p_alY^51A-l+ZhD zZpSP3un>K>9dG8U@QUIp=aka~Z~D@jw?&TXok)DUA3PV1msRS8j@<8lZpmn*DRL#M zB8!*V%77npJ&`0E#+48(lTUCbB*65t_Gi8>>w@%Jd0jr64tQ-k!-#m%zkb!)e-(XY zdhnfmMZ}U0`=RCj2#A|PPfQ4kf*BBha2IP_eVvO9tjCzDI2VegPxTqtTCj? zFJI@wdEA}H_f0<0_dh!DZ>*56 zp5h(9lsge#=X(22B|E*@_)SNrtu=iH+srXhns-TQ^Je2N->l47WImpF>5A%1+XWpy z-(*zIk}?Y76xnIJx~&24~Klh`7ZPnmFY9QJ-YY8c%`X0yGjtmS+ZV!7NO$% zr(rFlCiDRbcC6rlQ%OCx#Q2iTXA}OCVGsT$#l;wN?kC}< zg(}tMu^Q94J(yPvWE`;lnCZrDU*w}>FWcbvWw}yVZm&DM9H%7!Ycyxu6iw@@BAiLR z&)MI2xS}X(_ThY^qoRLq(0g+CEY#%=wY+!`Z?2`OB58lHeG=%ZZJqSWYV+l43E_*$ zFO{U*-H}UZmH7?g?->j?9zc@Vz7b12?YaBw?bZFumq1t76I71`r)XLml_MiqPH?0# zc5nqS$d#o_kid3yyCM*_b6V!=*YjLEYYjDa! z82Ualbo%JtYqspPpaU0xXZegT?QMGHi31;q_UlrWeADa;8gw@~HyDp|YHo-E8iLSA zYB8af8RIFoXIxm8qBEOAw0TM*iq&;5r3Odgf!Vu=X5GjMU)Qg`u5XW?$M-Ou99v}u zuyaETlGNAER=j{f!_kUmr;+=hwz1KZS0l_r#XT?5z_C$=*o21l=X;Tm%#02(e*-o; zWo@iE_QRE@a8WiKs;8P&17unw&Bjec2{-|^*Pu>*EvSb{(j{UBh#PbCF!4x1J}s!@ zNz!+*I6Oy_OIKIPd`gSboLa%T?6FIdYgyQFM8!gaB?@S?jBrT;aM-9pLtsHeXhB18 zK||c~I}of}<4(P&j{~m{Jl<9c8Ql37Ij7n^zdU91g4!ID2Hn9(&QAG-JlUc;%_Q{6 zx?@d!yjCN7e|i2C@NFpF`o4NEGUglK>?xl*1kw)qtk>4~T&lC-j6a>%Oz9$P8A-wh zB8Z}vb;vqQ!XSM9@|+3{MhnQ7)qF5P(G8oawJoQ=6hzgK3W{zS*T1=+@({$o;$_;Q z@g!t^3xt8fW=Dh^qThI~R=DL*6RB%G=#mSgTl%A9=x402#>t6E-LBya@N0 zg%=Dvn5)kqb{4#Zx{U_LQ^qHvF?6r?7A>eT^j!r%l;H^poE7Rv4Nti4C1JEVqJWw| zRgD^+PDEC*f*PJs-AiPE-oNG+E~rr)I0eG+1Rd^Al}cn|KhP9zP-8!!hcP^10rWsq zNZm_Dz$t+>f2tZamKzWx_S78W-HXThek6GRZg{v)N^CARq0+qBDbF$={1N&vLdF1Y zuZYP_%(3XIlVPl-t_}z@;Oc;|pt#Yg+?LU&^SA$of%_Q{O&GYofM~|R zjRiyt25vSWz9)F-sXG)cCw1QG!bfc-b-L-nM;!qrKwJSOefX$HprkJe7?M6Wi-$T{ zOux$#On3b3c$@{Bzw*A*kt0N7EJlG#QHBi~E`{vgnE~H$IGh0n;+>84^S7j-*Z(Yz z?gb87@ahF=>IHe~1tseRHR}Z}>IL2F1;gtF)9M9F>uQ#^*0xiL)ez_vQUZ8T?iwh^UmYv_-Y!G#L@16Ij{-RUBb+vt=ed-(R zKap8u4!%HE#{HkgwDJ!_$H-M{50|PiF`DLiUEr-4uv7_mM5}`$c%Ztzg8-K`LRB_YW)e!p`T^N znxLqHEZ5r-k81p5ZiO2;js=Gt+JYhv6qR2bH${*&egYRFce&113&OvFJzEQORY$bH zsP_;_e{KC59(C{Cu|p&O!_2hEvL5Y|-{6AuVbHbjaC!qYR*{m~2&T8MlLG9OHVs!> zh(tzG2_G70|x_qHJVEEjTCYLgiK)HuD5f;*k z3#TLH-NLcyfZR9Bg_UAPUFDzK91^mLj~8KOP0g4`c~!dSB)*j(b@c8|2K%X3Wl-Kv z^!32j2$60#mViIpMJgcHHl`#@i9h1rwM8c{MugwP|5@Og>ZP4&KI)RKaFQfctHddJ zt5zy$@NKMWnar+Rg^~$tzfltH7apQ=m9&uyjlv=0n0{JSN@WVQQufI@OW*)@qj$G~ zpN!rKPzpxL+;ZPLO@B+D3dxG&v_F3zn^-8jqmwdZtm0DYP$g}`N;_5buOvI|R0xD5 z0Rp)VbzDR4O+mEhj|Tf#>5P=`-yQswwauX9-s6AN^d@gsN%W1bQBeT_76@bn@C{2f zyx6;ySnkY&3O}7q!b$h7R8t!CS*X(?A9 z5YJl))J*tZ8&S?%meE-zNgu0G=I8mSG1h9QMGdi(IfVZLx*enVSA|1Vna-ysR{SI* z72bz6gViw%#F&C%wNt